@camstack/types 1.2.93 → 1.2.95

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
@@ -1530,12 +1530,25 @@ var RecordingBandSchema = zod.z.object({
1530
1530
  * segment is ever finalized and the camera records nothing anyway. At the 10 s
1531
1531
  * default segment length, 30 s leaves room for two full segments. `preBufferSec`
1532
1532
  * is retroactive only — it costs nothing at record time, it merely keeps
1533
- * already-written segments — so it is set to one segment length.
1533
+ * already-written segments.
1534
1534
  *
1535
- * An EXPLICIT `0` is left alone: that is an operator statement, not an omission.
1535
+ * ## Why `preBufferSec` is 15 and not one segment length
1536
+ *
1537
+ * It has to cover the broker's RECORDING pre-roll, which is media handed to the
1538
+ * writer from BEFORE the trigger (D192/D193). If this bound were narrower, the
1539
+ * keep gate would delete the very seconds the ring just supplied — two
1540
+ * authorities disagreeing about the same footage, which is the failure D191 was
1541
+ * written against. It is therefore set to `RECORDING_PRE_ROLL_MAX_MS` (15 s),
1542
+ * the widest window the broker can be configured to serve, so an operator
1543
+ * raising the cluster pre-roll can never walk past the gate silently. The
1544
+ * addon-side guard is
1545
+ * `packages/addon-pipeline/src/recorder/addon/__tests__/band-decision.spec.ts`.
1546
+ *
1547
+ * An EXPLICIT `0` is left alone: that is an operator statement, not an omission,
1548
+ * and it is excluded from that invariant by name.
1536
1549
  */
1537
1550
  var DEFAULT_EVENTS_BAND_BUFFER_SEC = {
1538
- preBufferSec: 10,
1551
+ preBufferSec: 15,
1539
1552
  postBufferSec: 30
1540
1553
  };
1541
1554
  /**
@@ -4223,11 +4236,15 @@ export default function block(ctx: AddonContext) {
4223
4236
  ): Promise<void> {
4224
4237
  const resolved: Record<string, unknown> = {}
4225
4238
  for (const [k, v] of Object.entries(args)) resolved[k] = substitute(v, vars)
4226
- const client = (ctx.api as unknown as Record<string, Record<string, { mutate: (i: unknown) => Promise<unknown> }>>)[capToRouterKey(cap)]
4227
- if (client === undefined || client[method] === undefined) {
4239
+ const client: unknown = Reflect.get(ctx.api, capToRouterKey(cap))
4240
+ const proc: unknown =
4241
+ typeof client === 'object' && client !== null ? Reflect.get(client, method) : undefined
4242
+ const mutate: unknown =
4243
+ typeof proc === 'object' && proc !== null ? Reflect.get(proc, 'mutate') : undefined
4244
+ if (typeof mutate !== 'function') {
4228
4245
  throw new Error('no such device method: ' + cap + '.' + method)
4229
4246
  }
4230
- await client[method]!.mutate({ ...resolved, deviceId })
4247
+ await mutate({ ...resolved, deviceId })
4231
4248
  }
4232
4249
 
4233
4250
  async function evaluateConditions(): Promise<boolean> {
@@ -14688,7 +14705,7 @@ var TrackEnvelopeSchema = zod.z.object({
14688
14705
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
14689
14706
  * keeps every scalar the list surfaces actually render (ids, class(es),
14690
14707
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
14691
- * zonesVisited, bestEventId, envelope, hasFace, hasRider) and returns `positions` /
14708
+ * zonesVisited, bestEventId, envelope, hasFace, hasEmbeddedFace, hasRider) and returns `positions` /
14692
14709
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
14693
14710
  * `getTrack`. Mirrors the event-store `projection` convention
14694
14711
  * (`getObjectEvents` et al.).
@@ -14999,6 +15016,35 @@ var TrackSchema = zod.z.object({
14999
15016
  */
15000
15017
  hasFace: zod.z.boolean().optional(),
15001
15018
  /**
15019
+ * This track has a face row IN THE GALLERY: a crop **and** an embedding — a
15020
+ * face an operator could ASSIGN to an identity.
15021
+ *
15022
+ * The STRICT twin of {@link hasFace}, and the pair only earns its keep
15023
+ * because the two disagree. `hasFace` is stamped at the TOP of the face
15024
+ * branch, before every gate, and means no more than "a face detector produced
15025
+ * a face detail". This one is stamped at the single moment the gallery row
15026
+ * LANDS — after `FaceRecognizer.onTrackEnd` successfully persists it, i.e.
15027
+ * past the embedding-magnitude verdict, the `minFacePx` detection gate, the
15028
+ * candidate gate, the imageless-track drop (no crop was ever captured) and
15029
+ * the crop-store drop. Everything between the detector and that insert can
15030
+ * legitimately refuse the face, so a flag written any earlier promises the
15031
+ * operator something to assign and delivers nothing.
15032
+ *
15033
+ * **Independent of recognition.** A face collected but never auto-matched is
15034
+ * still assignable — it is in fact the face an operator most wants to reach —
15035
+ * so this is NOT gated on `recognizedIdentityId`. Recognition lands in
15036
+ * `subLabel`; this says only that the raw material exists.
15037
+ *
15038
+ * **Set once, never cleared.** A track that produced a gallery row produced
15039
+ * one; deleting the row later is the gallery's business, not this flag's.
15040
+ *
15041
+ * **Absent ≠ false**, the same rule as {@link hasFace}: every row written
15042
+ * before the column omits it, and so does every server that predates the
15043
+ * field. A consumer must test `=== true` and render nothing otherwise —
15044
+ * never infer "no assignable face".
15045
+ */
15046
+ hasEmbeddedFace: zod.z.boolean().optional(),
15047
+ /**
15002
15048
  * This subject CONTAINS a folded rider — a person the rider-pairing step
15003
15049
  * ([D34](../decisions/adr-0034.md)) removed from the frame BEFORE the tracker,
15004
15050
  * so the passage is tracked once and as a VEHICLE.
@@ -16965,6 +17011,26 @@ var DetailResultSchema = zod.z.object({
16965
17011
  * the consuming gate treats as accept — never as reject.
16966
17012
  */
16967
17013
  embeddingMagnitude: zod.z.number().optional(),
17014
+ /**
17015
+ * Model that produced `embedding` — the SAME contract `labelModelId` has for
17016
+ * `label`, and for the same reason: the producing node is the only side that
17017
+ * knows which model actually ran, and re-deriving it downstream is exactly
17018
+ * the inference this model exists to forbid.
17019
+ *
17020
+ * Additive and optional, so a runner that predates it degrades to
17021
+ * "unmeasurable" rather than to a wrong answer — the consumer falls back to
17022
+ * its previous assumption instead of refusing.
17023
+ *
17024
+ * Its absence was not cosmetic. `FaceRecognizer` needs the probe's model id
17025
+ * to use `face-matcher`'s same-model gate (`sample.modelId !== probe.modelId`);
17026
+ * with nothing on the wire it took the id from the GALLERY, so the gate
17027
+ * compared the gallery against itself and could never fire. That turned the
17028
+ * one guard protecting the face feature space into a tautology, and it is
17029
+ * why swapping `face-embedding`'s model would NOT have stranded the enrolled
17030
+ * gallery as the catalog comment predicts — it would have scored the new
17031
+ * model's vectors against the old model's samples in one cosine space.
17032
+ */
17033
+ embeddingModelId: zod.z.string().optional(),
16968
17034
  label: zod.z.string().optional(),
16969
17035
  /**
16970
17036
  * The tier `label` occupies, copied VERBATIM from the producing step's
@@ -18170,6 +18236,24 @@ var CameraStatusSchema = zod.z.object({
18170
18236
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
18171
18237
  fetchedAt: zod.z.number()
18172
18238
  });
18239
+ /**
18240
+ * Why the dispatcher does NOT consider a device the node HAS for the
18241
+ * camera-root election. The list is the CONTRACT between the orchestrator's
18242
+ * `resolveInferenceDeviceEligibility` (which computes it) and the admin UI
18243
+ * (which renders it) — the orchestrator's `InferenceDeviceExclusionReason`
18244
+ * derives from this tuple so the two can never drift.
18245
+ *
18246
+ * Every one of these drops used to be silent, and an accelerator that is
18247
+ * enabled, available, and never chosen is indistinguishable from one that was
18248
+ * never configured. The reason is the difference.
18249
+ */
18250
+ var INFERENCE_DEVICE_EXCLUSION_REASONS = [
18251
+ "disabled",
18252
+ "unavailable",
18253
+ "cannot-host-camera-root",
18254
+ "accelerator-preferred"
18255
+ ];
18256
+ var InferenceDeviceExclusionReasonSchema = zod.z.enum(INFERENCE_DEVICE_EXCLUSION_REASONS);
18173
18257
  var NodeInferenceDeviceSchema = zod.z.object({
18174
18258
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
18175
18259
  key: zod.z.string(),
@@ -18200,7 +18284,17 @@ var NodeInferenceDeviceSchema = zod.z.object({
18200
18284
  * available per format; this is the stored selection that becomes the
18201
18285
  * default for EVERY camera landing on this accelerator.
18202
18286
  */
18203
- steps: zod.z.record(zod.z.string(), DeviceStepConfigSchema).optional()
18287
+ steps: zod.z.record(zod.z.string(), DeviceStepConfigSchema).optional(),
18288
+ /**
18289
+ * `null` when the device IS a camera-root candidate on this node; otherwise
18290
+ * the reason the dispatcher drops it. Computed by the SAME
18291
+ * `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
18292
+ * never disagree with the election — deriving it in the UI from
18293
+ * `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
18294
+ * the node's model catalog) and `accelerator-preferred` (needs the node-wide
18295
+ * "an accelerator is serving" predicate).
18296
+ */
18297
+ exclusion: InferenceDeviceExclusionReasonSchema.nullable()
18204
18298
  });
18205
18299
  var NodeInferenceDevicesSchema = zod.z.object({
18206
18300
  nodeId: zod.z.string(),
@@ -24520,7 +24614,41 @@ var ConnectionEndpointSchema = zod.z.object({
24520
24614
  */
24521
24615
  priority: zod.z.number()
24522
24616
  });
24523
- var GetConnectionEndpointsResultSchema = zod.z.object({ endpoints: zod.z.array(ConnectionEndpointSchema).readonly() });
24617
+ /**
24618
+ * Where the advertised local port came from. Ordered most → least
24619
+ * authoritative, and the whole point of returning it: a client must be able to
24620
+ * tell a FACT about the hub's socket from an echo of its own guess.
24621
+ */
24622
+ var LocalPortSourceEnum = zod.z.enum([
24623
+ "server-config",
24624
+ "server-env",
24625
+ "caller-hint",
24626
+ "default"
24627
+ ]);
24628
+ /** The port every LAN/loopback `baseUrl` in the same result was built with. */
24629
+ var AdvertisedLocalPortSchema = zod.z.object({
24630
+ port: zod.z.number().int().min(1).max(65535),
24631
+ source: LocalPortSourceEnum
24632
+ });
24633
+ var GetConnectionEndpointsResultSchema = zod.z.object({
24634
+ endpoints: zod.z.array(ConnectionEndpointSchema).readonly(),
24635
+ /**
24636
+ * The port the hub built the LAN/loopback URLs with, and where that number
24637
+ * came from.
24638
+ *
24639
+ * Returned rather than merely applied, because "the URL is right" and "the
24640
+ * client can KNOW the URL is right" are different properties. A client that
24641
+ * only sees a corrected URL cannot distinguish a hub that fixed the port from
24642
+ * a hub that echoed the port the client sent, so it cannot decide whether to
24643
+ * race the candidate or discard it. With `source` it can: anything but
24644
+ * `caller-hint` is the hub's own socket.
24645
+ *
24646
+ * Absent on hubs predating this field — a client that finds it missing is
24647
+ * talking to an echoing hub and must degrade exactly as it does for
24648
+ * `caller-hint`.
24649
+ */
24650
+ localPort: AdvertisedLocalPortSchema
24651
+ });
24524
24652
  /**
24525
24653
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
24526
24654
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -24566,10 +24694,35 @@ var localNetworkCapability = {
24566
24694
  * Honours `getAllowedAddresses()` when set — addresses outside
24567
24695
  * the allowlist are dropped (the public tunnel + loopback are
24568
24696
  * always included as escape hatches).
24697
+ *
24698
+ * **The port is the hub's, not the caller's** (D62 — a function's fact
24699
+ * belongs to whoever already owns it). This method used to take a `port`
24700
+ * and echo it onto every LAN and loopback URL it advertised, which made
24701
+ * every client a second authority on a socket only the hub binds. A viewer
24702
+ * configured with a port-less public URL (`https://camstack.example.top`)
24703
+ * infers 443 from the scheme, asks with 443, and was handed
24704
+ * `https://192.168.1.9:443` for a hub listening on 4443. That candidate
24705
+ * cannot ever answer, and losing its race reads in the log exactly like
24706
+ * "the LAN was tried and it was slower" — so an operator sitting on his own
24707
+ * WiFi stayed on the public tunnel for weeks, with the LAN candidate born
24708
+ * dead on every single connect.
24709
+ *
24710
+ * The hub now resolves its own listen port and reports it as `localPort`,
24711
+ * with the SOURCE of the number, so a client can tell a fact from an echo.
24712
+ *
24713
+ * Out of scope on purpose: a LAN URL that is not the hub's own socket (a
24714
+ * reverse proxy in front of it on another port) is not this method's to
24715
+ * invent either — pin it with `setNotificationEndpoint` / a configured
24716
+ * origin, which is an operator statement rather than a guess.
24569
24717
  */
24570
24718
  getConnectionEndpoints: require_sleep.method(zod.z.object({
24571
- /** Local hub HTTP port to use in base URLs. */
24572
- port: zod.z.number().int().min(1).max(65535),
24719
+ /**
24720
+ * LEGACY HINT — do not send from new code. Kept optional so clients
24721
+ * written against the echoing contract keep working; the hub uses it
24722
+ * only when it cannot read its own port, and says so via
24723
+ * `localPort.source === 'caller-hint'`.
24724
+ */
24725
+ port: zod.z.number().int().min(1).max(65535).optional(),
24573
24726
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24574
24727
  * candidate. Default `true`. */
24575
24728
  includeLoopback: zod.z.boolean().optional(),
@@ -32343,11 +32496,63 @@ function startReachabilityPoll(options) {
32343
32496
  //#endregion
32344
32497
  //#region src/device/runtime-state-helpers.ts
32345
32498
  var LAST_FETCHED_FIELD = "lastFetchedAt";
32499
+ /**
32500
+ * How long a bridge stops re-attempting a refresh that did not land.
32501
+ *
32502
+ * Sized against the failure it exists for: a camera whose control plane
32503
+ * is unreachable costs the FULL connect give-up (~3s on Linux when the
32504
+ * neighbour never answers, up to the client's own timeout otherwise) on
32505
+ * every attempt, and a failed refresh never advances `lastFetchedAt`, so
32506
+ * without a cooldown the slice is permanently stale and EVERY read pays
32507
+ * that price. One attempt per minute is enough to notice the camera
32508
+ * coming back; per-read is enough to stall the viewer's first paint.
32509
+ */
32510
+ var RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS = 6e4;
32511
+ /**
32512
+ * The freshness window for a cap whose value changes ONLY when WE write it.
32513
+ *
32514
+ * The usual `staleMs: 10_000` is tuned for a reading that moves on its own — a
32515
+ * battery level, a day/night state that flips at dusk. It is the wrong number
32516
+ * for a cap like `privacy-mask`, whose two facts (is the video mask on, is the
32517
+ * microphone on) change when an operator changes them, through a `setMask` /
32518
+ * `setAudioEnabled` that **re-reads the camera and rewrites this very slice**.
32519
+ * A write is therefore its own invalidation: the window never delays an
32520
+ * operator's own change, however long it is.
32521
+ *
32522
+ * What the window really bounds is the ONE case we do not write: somebody
32523
+ * changing the mask in the vendor app or the camera's web UI. Ten minutes is
32524
+ * the trade — that change is visible within ten minutes, and a fleet of 29
32525
+ * cameras behind a page that polls every 5s costs about 3 camera round trips a
32526
+ * minute instead of 696
32527
+ * ([D221](../../../../docs/decisions/adr-0221-a-polled-list-never-dials-a-camera.md)).
32528
+ *
32529
+ * Use it ONLY with {@link RuntimeStateStaleReadPolicy} `'serve-and-revalidate'`.
32530
+ * On the awaiting default a window this long does not remove the stall, it
32531
+ * merely makes it rarer and just as long.
32532
+ */
32533
+ var OPERATOR_WRITTEN_STALE_MS = 10 * 6e4;
32346
32534
  function createRuntimeStateBridge(params) {
32347
32535
  const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
32348
32536
  const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
32537
+ const staleRead = params.staleRead ?? "await-refresh";
32349
32538
  /** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
32350
32539
  let missCooldownUntil = 0;
32540
+ /**
32541
+ * The refresh this bridge currently has in the air, if any.
32542
+ *
32543
+ * Providers single-flight their own camera client, so this is not what stops
32544
+ * a second round trip. What it stops is a POLLED reader JOINING one: under
32545
+ * `'serve-and-revalidate'` a read that finds a refresh already outstanding is
32546
+ * answered from the slice at once — even a cold, empty slice, which reports
32547
+ * UNKNOWN, which is the truth about a camera nobody has reached.
32548
+ *
32549
+ * That distinction is the whole cost of an unreachable camera. Measured on
32550
+ * the live hub (device 3629, an offline battery Reolink): its refresh takes
32551
+ * 23.1s to give up, so without this every 5s poll landing inside those 23s
32552
+ * joined the wait and spent the caller's full 1.2s source budget — five
32553
+ * stalled polls per cooldown cycle, for one camera, forever.
32554
+ */
32555
+ let refreshInFlight = null;
32351
32556
  const readFetchedAt = () => {
32352
32557
  const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
32353
32558
  return typeof value === "number" ? value : 0;
@@ -32368,14 +32573,14 @@ function createRuntimeStateBridge(params) {
32368
32573
  }
32369
32574
  });
32370
32575
  };
32371
- const ensureFresh = async () => {
32372
- const slice = runtimeState.getCapState(cap.name);
32373
- const fetchedAt = readFetchedAt();
32374
- if (slice && Date.now() - fetchedAt <= staleMs) {
32375
- missCooldownUntil = 0;
32376
- return;
32377
- }
32378
- if (Date.now() < missCooldownUntil) return;
32576
+ /**
32577
+ * One refresh attempt, plus the LANDED check that decides the cooldown.
32578
+ *
32579
+ * @param fetchedAt What `lastFetchedAt` was before the attempt — the only
32580
+ * evidence the bridge has that the refresh persisted
32581
+ * anything, since providers swallow their own camera errors.
32582
+ */
32583
+ const runRefresh = async (fetchedAt) => {
32379
32584
  try {
32380
32585
  await refresh();
32381
32586
  } catch (err) {
@@ -32391,6 +32596,41 @@ function createRuntimeStateBridge(params) {
32391
32596
  }
32392
32597
  openMissCooldown(void 0);
32393
32598
  };
32599
+ /**
32600
+ * Start a refresh and remember it, at most one at a time.
32601
+ *
32602
+ * It never rejects: under `'serve-and-revalidate'` the caller is answered
32603
+ * from the slice either way, so a floating rejection would take the process
32604
+ * down for a fault the miss cooldown has already recorded and logged.
32605
+ */
32606
+ const startRefresh = (fetchedAt) => {
32607
+ const existing = refreshInFlight;
32608
+ if (existing !== null) return existing;
32609
+ const started = runRefresh(fetchedAt).catch(() => void 0).finally(() => {
32610
+ refreshInFlight = null;
32611
+ });
32612
+ refreshInFlight = started;
32613
+ return started;
32614
+ };
32615
+ const ensureFresh = async () => {
32616
+ const slice = runtimeState.getCapState(cap.name);
32617
+ const fetchedAt = readFetchedAt();
32618
+ if (slice && Date.now() - fetchedAt <= staleMs) {
32619
+ missCooldownUntil = 0;
32620
+ return;
32621
+ }
32622
+ if (Date.now() < missCooldownUntil) return;
32623
+ if (staleRead === "serve-and-revalidate") {
32624
+ if (refreshInFlight !== null) return;
32625
+ if (slice && fetchedAt > 0) {
32626
+ startRefresh(fetchedAt);
32627
+ return;
32628
+ }
32629
+ await startRefresh(fetchedAt);
32630
+ return;
32631
+ }
32632
+ await runRefresh(fetchedAt);
32633
+ };
32394
32634
  const projectStatus = () => {
32395
32635
  const slice = runtimeState.getCapState(cap.name);
32396
32636
  if (!slice) return empty();
@@ -44334,6 +44574,166 @@ function readTimelapseGeneratedAt(rule, deviceId) {
44334
44574
  return rule.lastGeneratedAt ?? 0;
44335
44575
  }
44336
44576
  //#endregion
44577
+ //#region src/pipeline/cluster-model-scope.ts
44578
+ /**
44579
+ * A model choice has a SCOPE, and a scope change has a workflow.
44580
+ *
44581
+ * ## The two scopes
44582
+ *
44583
+ * Almost every pipeline step's model is a **node** question: which build runs
44584
+ * fastest on THIS accelerator. Two nodes may legitimately answer differently —
44585
+ * a Coral runs the tflite SSD, a Mac runs the coreml YOLO — because the answers
44586
+ * are compared against nothing. They produce boxes, and a box is a box.
44587
+ *
44588
+ * Two steps are not like that. `face-embedding` and `clip-embedding` produce
44589
+ * **vectors that land in ONE shared index** (`identity_samples`,
44590
+ * `vec_object_clip`) and are ranked against each other by cosine similarity.
44591
+ * Cosine similarity between two encoders is not a weak signal, it is a
44592
+ * meaningless number — and nothing inside a vector can tell you it happened.
44593
+ * So for those two steps the model is a **cluster** question with exactly one
44594
+ * answer, stored in exactly one row.
44595
+ *
44596
+ * That is the same argument [D52](../../../../docs/decisions/adr-0052.md) makes
44597
+ * for the crop convention (`detail-crop.ts`), one level up: the crop decides
44598
+ * WHAT pixels the encoder sees, this decides WHICH encoder sees them. Both are
44599
+ * cluster-wide for the same reason and neither may become per-node.
44600
+ *
44601
+ * ## Why the registry lives here and not in the step catalog
44602
+ *
44603
+ * The scope itself is declared on the step, in
44604
+ * `detection-pipeline/registry/step-definitions.ts` (`StepDefinition.modelScope`)
44605
+ * — that is where a new step declares what kind of choice its model is. But the
44606
+ * catalog lives inside `addon-pipeline`, and three packages that must NOT
44607
+ * import an addon need to know the set: the orchestrator (which renders and
44608
+ * stores the row), post-analysis (which pins the row for a re-embed pass) and
44609
+ * admin-ui (which mirrors it). So the set is mirrored here, and
44610
+ * `scripts/check-cluster-model-scope.ts` fails the build when the two disagree.
44611
+ * A hand-copied key that stops matching is exactly how a setting silently stops
44612
+ * arriving while both sides still look correct.
44613
+ *
44614
+ * ## The defaults ARE today's behaviour
44615
+ *
44616
+ * {@link DEFAULT_CLUSTER_STEP_MODELS} reproduces the catalog `defaultModelId`
44617
+ * of both steps. Declaring the scope therefore changes nothing on the day it
44618
+ * ships: the cluster row starts out saying what every node was already doing.
44619
+ * Changing it is legitimate — that is what the knob is for — but it invalidates
44620
+ * the stored vectors of that surface, and must be followed by a re-embed pass
44621
+ * (`face-reembed-pass.ts`, `embedding-rebuild.ts`).
44622
+ */
44623
+ /**
44624
+ * The cluster-scoped steps. Mirrored from the catalog's `modelScope: 'cluster'`
44625
+ * declarations and guarded by `scripts/check-cluster-model-scope.ts`.
44626
+ */
44627
+ var CLUSTER_MODEL_SCOPED_STEPS = [{
44628
+ stepId: "face-embedding",
44629
+ label: "Face recognition model",
44630
+ defaultModelId: "arcface-r100",
44631
+ indexName: "the enrolled face gallery",
44632
+ options: [
44633
+ {
44634
+ id: "arcface-r100",
44635
+ label: "ArcFace (ResNet34)"
44636
+ },
44637
+ {
44638
+ id: "auraface-r100",
44639
+ label: "AuraFace R100"
44640
+ },
44641
+ {
44642
+ id: "inception-resnet-v1",
44643
+ label: "Inception ResNet V1"
44644
+ }
44645
+ ]
44646
+ }, {
44647
+ stepId: "clip-embedding",
44648
+ label: "Semantic search model",
44649
+ defaultModelId: "mobileclip-s1",
44650
+ indexName: "the object semantic-search index",
44651
+ options: [{
44652
+ id: "mobileclip-s1",
44653
+ label: "MobileCLIP S1"
44654
+ }, {
44655
+ id: "mobileclip-s2",
44656
+ label: "MobileCLIP S2"
44657
+ }]
44658
+ }];
44659
+ /**
44660
+ * Store identity of the cluster row inside `pipeline-orchestrator`'s GLOBAL
44661
+ * (cluster-wide) settings — the same owner, and for the same reason, as the
44662
+ * detail-crop convention: hub-resident and neutral between the addons that
44663
+ * read it.
44664
+ */
44665
+ var CLUSTER_MODEL_SECTION_ID = "cluster-step-models";
44666
+ /**
44667
+ * The settings key holding one step's cluster model choice.
44668
+ *
44669
+ * Step-qualified so the keys stay unique across the addon's whole schema, which
44670
+ * is what lets {@link pickClusterStepModels} walk every section instead of
44671
+ * trusting the section id.
44672
+ */
44673
+ function clusterModelSettingKey(stepId) {
44674
+ return `clusterModel:${stepId}`;
44675
+ }
44676
+ /** True when this step's model is a cluster-wide choice rather than a per-node one. */
44677
+ function isClusterScopedStep(stepId) {
44678
+ return CLUSTER_MODEL_SCOPED_STEPS.some((s) => s.stepId === stepId);
44679
+ }
44680
+ /** The cluster row when nobody has configured one — today's catalog defaults. */
44681
+ var DEFAULT_CLUSTER_STEP_MODELS = Object.freeze(Object.fromEntries(CLUSTER_MODEL_SCOPED_STEPS.map((s) => [s.stepId, s.defaultModelId])));
44682
+ /** A stored choice is a non-empty model id, or it is not a choice. */
44683
+ var ChosenModelIdSchema = zod.z.string().min(1);
44684
+ /**
44685
+ * Narrow a FLAT settings record to the cluster row.
44686
+ *
44687
+ * Per-FIELD fallback, deliberately (same rule as `readDetailCropConvention`): a
44688
+ * junk face model must not also discard a valid clip model. An absent, empty or
44689
+ * non-string value resolves to the step's catalog default — the historical
44690
+ * behaviour — never to a blank id, because a blank id downstream becomes
44691
+ * "substitute the format default", which is precisely the substitution this
44692
+ * scope exists to forbid.
44693
+ */
44694
+ function readClusterStepModels(config) {
44695
+ const out = {};
44696
+ for (const step of CLUSTER_MODEL_SCOPED_STEPS) {
44697
+ const parsed = ChosenModelIdSchema.safeParse(config[clusterModelSettingKey(step.stepId)]);
44698
+ out[step.stepId] = parsed.success ? parsed.data : step.defaultModelId;
44699
+ }
44700
+ return out;
44701
+ }
44702
+ function isHydratedField$2(entry) {
44703
+ return typeof entry === "object" && entry !== null && "key" in entry;
44704
+ }
44705
+ /**
44706
+ * Extract the cluster row from an `addon-settings.getGlobalSettings` payload.
44707
+ *
44708
+ * Walks EVERY section rather than looking inside {@link CLUSTER_MODEL_SECTION_ID}
44709
+ * alone: the keys are unique across the addon's schema, and a section rename
44710
+ * must not silently revert the whole cluster to the defaults. A `null` payload
44711
+ * (addon mid-boot) is the defaults.
44712
+ */
44713
+ function pickClusterStepModels(view) {
44714
+ if (view === null) return DEFAULT_CLUSTER_STEP_MODELS;
44715
+ const flat = {};
44716
+ const wanted = new Set(CLUSTER_MODEL_SCOPED_STEPS.map((s) => clusterModelSettingKey(s.stepId)));
44717
+ for (const section of view.sections) for (const entry of section.fields) {
44718
+ if (!isHydratedField$2(entry) || typeof entry.key !== "string") continue;
44719
+ if (wanted.has(entry.key)) flat[entry.key] = entry.value;
44720
+ }
44721
+ return readClusterStepModels(flat);
44722
+ }
44723
+ /**
44724
+ * The cluster model for `stepId`, or `null` when the step is node-scoped.
44725
+ *
44726
+ * `null` is the signal to LEAVE THE CALLER'S RESOLUTION ALONE — it is not "no
44727
+ * model". Returning a default here for a node-scoped step would quietly
44728
+ * override every per-(node,device) selection in the fleet.
44729
+ */
44730
+ function resolveClusterStepModelId(stepId, models) {
44731
+ const step = CLUSTER_MODEL_SCOPED_STEPS.find((s) => s.stepId === stepId);
44732
+ if (step === void 0) return null;
44733
+ const chosen = models[stepId];
44734
+ return chosen !== void 0 && chosen !== "" ? chosen : step.defaultModelId;
44735
+ }
44736
+ //#endregion
44337
44737
  //#region src/pipeline/detail-crop.ts
44338
44738
  /**
44339
44739
  * THE detail-crop convention — the single derivation of the rectangle a
@@ -44448,6 +44848,30 @@ function pickDetailCropConvention(view) {
44448
44848
  }
44449
44849
  return readDetailCropConvention(flat);
44450
44850
  }
44851
+ /**
44852
+ * The normalised box that means "the image IS the subject".
44853
+ *
44854
+ * Used when the stored pixels are ALREADY the model's input — the 112×112
44855
+ * aligned face template a gallery sample carries. There is no rectangle left to
44856
+ * choose, and the convention must not choose one: re-detecting and re-aligning
44857
+ * over a template applies a SECOND warp, passes every gate, and produces a
44858
+ * plausible useless vector (session 2026-08-20 §3).
44859
+ *
44860
+ * Sending it as the whole frame with this box is exact rather than merely
44861
+ * convenient, and the reason is a property of {@link deriveDetailCropRect}, not
44862
+ * a coincidence: padding a full-frame box can only push OUTSIDE the frame, and
44863
+ * both edge modes bring it straight back — unsquared truncates, squared cannot
44864
+ * exceed the frame's short side and then slides inside. So for a SQUARE image
44865
+ * the derivation is the identity under every convention value, and an operator
44866
+ * changing the crop margin cannot silently re-cut an enrolled template.
44867
+ * `face-reembed-pass.spec.ts` asserts that identity against the real function.
44868
+ */
44869
+ var FULL_IMAGE_BBOX = Object.freeze({
44870
+ x: 0,
44871
+ y: 0,
44872
+ w: 1,
44873
+ h: 1
44874
+ });
44451
44875
  /** Slider bounds for the operator-facing padding knob (orchestrator settings UI). */
44452
44876
  var DETAIL_CROP_PADDING_FIELD = {
44453
44877
  min: 0,
@@ -46782,6 +47206,8 @@ exports.CAPABILITY_ROUTER_KEYS = CAPABILITY_ROUTER_KEYS;
46782
47206
  exports.CAP_NAMES_WITH_STATUS = CAP_NAMES_WITH_STATUS;
46783
47207
  exports.CAP_NODE_PIN_CONTEXT_KEY = require_sleep.CAP_NODE_PIN_CONTEXT_KEY;
46784
47208
  exports.CAP_PROVIDER_KIND_MAP = CAP_PROVIDER_KIND_MAP;
47209
+ exports.CLUSTER_MODEL_SCOPED_STEPS = CLUSTER_MODEL_SCOPED_STEPS;
47210
+ exports.CLUSTER_MODEL_SECTION_ID = CLUSTER_MODEL_SECTION_ID;
46785
47211
  exports.COCO_80_LABELS = COCO_80_LABELS;
46786
47212
  exports.COCO_TO_MACRO = COCO_TO_MACRO;
46787
47213
  exports.CONNECTION_TEST_TIMEOUT_MS = CONNECTION_TEST_TIMEOUT_MS;
@@ -46869,6 +47295,7 @@ exports.DECLARED_DEVICE_SWEEP_LIMIT = DECLARED_DEVICE_SWEEP_LIMIT;
46869
47295
  exports.DECLARED_INTEGRATION_FIXED_KEY = DECLARED_INTEGRATION_FIXED_KEY;
46870
47296
  exports.DEFAULT_ADDON_PLACEMENT = DEFAULT_ADDON_PLACEMENT;
46871
47297
  exports.DEFAULT_AUDIO_ANALYZER_CONFIG = DEFAULT_AUDIO_ANALYZER_CONFIG;
47298
+ exports.DEFAULT_CLUSTER_STEP_MODELS = DEFAULT_CLUSTER_STEP_MODELS;
46872
47299
  exports.DEFAULT_DECODER_HWACCEL_CONFIG = DEFAULT_DECODER_HWACCEL_CONFIG;
46873
47300
  exports.DEFAULT_DETAIL_CROP_CONVENTION = DEFAULT_DETAIL_CROP_CONVENTION;
46874
47301
  exports.DEFAULT_EVENTS_BAND_BUFFER_SEC = DEFAULT_EVENTS_BAND_BUFFER_SEC;
@@ -46987,6 +47414,7 @@ exports.ExpressionGlobalBindingSchema = ExpressionGlobalBindingSchema;
46987
47414
  exports.ExpressionLiteralBindingSchema = ExpressionLiteralBindingSchema;
46988
47415
  exports.ExpressionParseError = ExpressionParseError;
46989
47416
  exports.ExpressionSourceSchema = ExpressionSourceSchema;
47417
+ exports.FULL_IMAGE_BBOX = FULL_IMAGE_BBOX;
46990
47418
  exports.FanControlStatusSchema = FanControlStatusSchema;
46991
47419
  exports.FanDirectionSchema = FanDirectionSchema;
46992
47420
  exports.FeatureManifestSchema = FeatureManifestSchema;
@@ -47013,6 +47441,7 @@ exports.HistoryResolutionEnum = HistoryResolutionEnum;
47013
47441
  exports.HumidifierStatusSchema = HumidifierStatusSchema;
47014
47442
  exports.HumiditySensorStatusSchema = HumiditySensorStatusSchema;
47015
47443
  exports.HvacModeSchema = HvacModeSchema;
47444
+ exports.INFERENCE_DEVICE_EXCLUSION_REASONS = INFERENCE_DEVICE_EXCLUSION_REASONS;
47016
47445
  exports.ImageContractSchema = ImageContractSchema;
47017
47446
  exports.ImageContractStateSchema = ImageContractStateSchema;
47018
47447
  exports.ImageRotateSchema = ImageRotateSchema;
@@ -47020,6 +47449,7 @@ exports.ImageSettingsOptionsSchema = ImageSettingsOptionsSchema;
47020
47449
  exports.ImageSettingsPatchSchema = ImageSettingsPatchSchema;
47021
47450
  exports.ImageSettingsStatusSchema = ImageSettingsStatusSchema;
47022
47451
  exports.ImageStatusSchema = ImageStatusSchema;
47452
+ exports.InferenceDeviceExclusionReasonSchema = InferenceDeviceExclusionReasonSchema;
47023
47453
  exports.IngestOwnerSchema = IngestOwnerSchema;
47024
47454
  exports.InstalledPackageSchema = InstalledPackageSchema;
47025
47455
  exports.IntegrationLiteSchema = IntegrationLiteSchema;
@@ -47232,6 +47662,7 @@ exports.NotificationFormatSchema = NotificationFormatSchema;
47232
47662
  exports.NotificationSchema = NotificationSchema;
47233
47663
  exports.NotifierStatusSchema = NotifierStatusSchema;
47234
47664
  exports.NumericSensorStatusSchema = NumericSensorStatusSchema;
47665
+ exports.OPERATOR_WRITTEN_STALE_MS = OPERATOR_WRITTEN_STALE_MS;
47235
47666
  exports.OPS_LOG_DEFAULT_LIMIT = OPS_LOG_DEFAULT_LIMIT;
47236
47667
  exports.OPS_LOG_RING_DEFAULT_MAX = OPS_LOG_RING_DEFAULT_MAX;
47237
47668
  exports.OauthIntegrationDescriptorSchema = OauthIntegrationDescriptorSchema;
@@ -47314,6 +47745,7 @@ exports.RESERVED_BINDING_NAMES = RESERVED_BINDING_NAMES;
47314
47745
  exports.RESTORED_CAP_NAMES = RESTORED_CAP_NAMES;
47315
47746
  exports.RUNTIME_DEFAULTS = RUNTIME_DEFAULTS;
47316
47747
  exports.RUNTIME_STATE_POLICY = RUNTIME_STATE_POLICY;
47748
+ exports.RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS = RUNTIME_STATE_REFRESH_MISS_COOLDOWN_MS;
47317
47749
  exports.RUNTIME_TO_FORMAT = RUNTIME_TO_FORMAT;
47318
47750
  exports.RawStateResultSchema = require_sleep.RawStateResultSchema;
47319
47751
  exports.ReadGopBytesResultSchema = ReadGopBytesResultSchema;
@@ -47645,6 +48077,7 @@ exports.classifyBearerPrincipal = classifyBearerPrincipal;
47645
48077
  exports.classifyStream = classifyStream;
47646
48078
  exports.classifyStreams = classifyStreams;
47647
48079
  exports.climateControlCapability = climateControlCapability;
48080
+ exports.clusterModelSettingKey = clusterModelSettingKey;
47648
48081
  exports.collectHydratedFieldEntries = require_sleep.collectHydratedFieldEntries;
47649
48082
  exports.collectHydratedFieldValues = require_sleep.collectHydratedFieldValues;
47650
48083
  exports.colorCapability = colorCapability;
@@ -47763,6 +48196,7 @@ exports.isAudioLabelSelected = isAudioLabelSelected;
47763
48196
  exports.isAudioRule = isAudioRule;
47764
48197
  exports.isBaseConditionKey = isBaseConditionKey;
47765
48198
  exports.isBatteryPresenceFault = isBatteryPresenceFault;
48199
+ exports.isClusterScopedStep = isClusterScopedStep;
47766
48200
  exports.isCollectionArrayMethod = isCollectionArrayMethod;
47767
48201
  exports.isDeployableToAgent = isDeployableToAgent;
47768
48202
  exports.isDetectionMacroClass = isDetectionMacroClass;
@@ -47846,6 +48280,7 @@ exports.parseStreamParamsFormPatch = parseStreamParamsFormPatch;
47846
48280
  exports.patchAudio = patchAudio;
47847
48281
  exports.petFeederCapability = petFeederCapability;
47848
48282
  exports.pickAccessoryControl = pickAccessoryControl;
48283
+ exports.pickClusterStepModels = pickClusterStepModels;
47849
48284
  exports.pickDetailCropConvention = pickDetailCropConvention;
47850
48285
  exports.pickNativeLeaseOverride = pickNativeLeaseOverride;
47851
48286
  exports.pickPreferredRtspEntry = pickPreferredRtspEntry;
@@ -47869,6 +48304,7 @@ exports.procedureAuthKey = procedureAuthKey;
47869
48304
  exports.ptzAutotrackCapability = ptzAutotrackCapability;
47870
48305
  exports.ptzCapability = ptzCapability;
47871
48306
  exports.pythonScriptForBackend = pythonScriptForBackend;
48307
+ exports.readClusterStepModels = readClusterStepModels;
47872
48308
  exports.readDetailCropConvention = readDetailCropConvention;
47873
48309
  exports.readDeviceStateFrom = readDeviceStateFrom;
47874
48310
  exports.readNativeLeaseOverride = readNativeLeaseOverride;
@@ -47886,6 +48322,7 @@ exports.resolveAddonGroup = resolveAddonGroup;
47886
48322
  exports.resolveAddonPlacement = resolveAddonPlacement;
47887
48323
  exports.resolveAddonRuntime = resolveAddonRuntime;
47888
48324
  exports.resolveCapMount = require_sleep.resolveCapMount;
48325
+ exports.resolveClusterStepModelId = resolveClusterStepModelId;
47889
48326
  exports.resolveDetectionRuntime = resolveDetectionRuntime;
47890
48327
  exports.resolveDeviceControlKind = resolveDeviceControlKind;
47891
48328
  exports.resolveDeviceProfile = resolveDeviceProfile;