@camstack/addon-osd-manager 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. package/dist/{MotionZonesSettings-DDk0KstC.mjs → MotionZonesSettings-DlZtvCFl.mjs} +2 -2
  2. package/dist/{PrivacyMaskSettings-CsXnMGKT.mjs → PrivacyMaskSettings-lyGUWV_l.mjs} +4 -4
  3. package/dist/{SceneMonitorEditor-8C9vWyuL.mjs → SceneMonitorEditor-dYpiaZEg.mjs} +3 -3
  4. package/dist/_stub.js +196 -196
  5. package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_osd_manager_page-DAdBzUD3.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_osd_manager_page-DPWjcIBk.mjs} +4 -4
  6. package/dist/_virtual_mf___mfe_internal__addon_osd_manager_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CoDIESQw.mjs +26 -0
  7. package/dist/{hostInit-BB5iEVB7.mjs → hostInit-B9K1gn0U.mjs} +3 -3
  8. package/dist/index.js +359 -74
  9. package/dist/index.mjs +359 -74
  10. package/dist/{player-overlays-C5TVZDIL.mjs → player-overlays-DIXY-7_H.mjs} +1 -1
  11. package/dist/remoteEntry.js +1 -1
  12. package/dist/{responsive-B6685e2G.mjs → responsive-7SgEY_K5.mjs} +1 -1
  13. package/dist/{square-ezK9FWt6.mjs → square-qJlpvL3B.mjs} +1 -1
  14. package/dist/{trash-2-BoPZpFI0.mjs → trash-2-CZ_J-874.mjs} +1 -1
  15. package/dist/{use-device-snapshot-BDmozw2y.mjs → use-device-snapshot--AcJ2F4s.mjs} +1 -1
  16. package/dist/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-7VFK3Vnq.mjs → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-CFOj1Hwh.mjs} +1 -1
  17. package/package.json +1 -1
  18. package/dist/_virtual_mf___mfe_internal__addon_osd_manager_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-C9zRtW6C.mjs +0 -26
package/dist/index.mjs CHANGED
@@ -7637,7 +7637,7 @@ var RecordingBandSchema = object({
7637
7637
  postBufferSec: number().min(0).optional()
7638
7638
  });
7639
7639
  ({
7640
- preBufferSec: 10,
7640
+ preBufferSec: 15,
7641
7641
  postBufferSec: 30
7642
7642
  }).postBufferSec * 1e3;
7643
7643
  /**
@@ -15318,128 +15318,103 @@ var COCO_TO_MACRO = {
15318
15318
  var AUDIO_MACRO_LABELS = [
15319
15319
  {
15320
15320
  id: "speech",
15321
- name: "Speech",
15322
- icon: "🗣️"
15321
+ name: "Speech"
15323
15322
  },
15324
15323
  {
15325
15324
  id: "scream",
15326
- name: "Scream / Shout",
15327
- icon: "😱"
15325
+ name: "Scream / Shout"
15328
15326
  },
15329
15327
  {
15330
15328
  id: "crying",
15331
- name: "Crying / Baby",
15332
- icon: "😢"
15329
+ name: "Crying / Baby"
15333
15330
  },
15334
15331
  {
15335
15332
  id: "laughter",
15336
- name: "Laughter",
15337
- icon: "😂"
15333
+ name: "Laughter"
15338
15334
  },
15339
15335
  {
15340
15336
  id: "music",
15341
- name: "Music",
15342
- icon: "🎵"
15337
+ name: "Music"
15343
15338
  },
15344
15339
  {
15345
15340
  id: "dog",
15346
- name: "Dog",
15347
- icon: "🐕"
15341
+ name: "Dog"
15348
15342
  },
15349
15343
  {
15350
15344
  id: "cat",
15351
- name: "Cat",
15352
- icon: "🐈"
15345
+ name: "Cat"
15353
15346
  },
15354
15347
  {
15355
15348
  id: "bird",
15356
- name: "Bird",
15357
- icon: "🐦"
15349
+ name: "Bird"
15358
15350
  },
15359
15351
  {
15360
15352
  id: "animal",
15361
- name: "Animal (other)",
15362
- icon: "🐾"
15353
+ name: "Animal (other)"
15363
15354
  },
15364
15355
  {
15365
15356
  id: "alarm",
15366
- name: "Alarm / Siren",
15367
- icon: "🚨"
15357
+ name: "Alarm / Siren"
15368
15358
  },
15369
15359
  {
15370
15360
  id: "doorbell",
15371
- name: "Doorbell / Knock",
15372
- icon: "🔔"
15361
+ name: "Doorbell / Knock"
15373
15362
  },
15374
15363
  {
15375
15364
  id: "glass_breaking",
15376
- name: "Glass Breaking",
15377
- icon: "💥"
15365
+ name: "Glass Breaking"
15378
15366
  },
15379
15367
  {
15380
15368
  id: "gunshot",
15381
- name: "Gunshot / Explosion",
15382
- icon: "💣"
15369
+ name: "Gunshot / Explosion"
15383
15370
  },
15384
15371
  {
15385
15372
  id: "vehicle",
15386
- name: "Vehicle",
15387
- icon: "🚗"
15373
+ name: "Vehicle"
15388
15374
  },
15389
15375
  {
15390
15376
  id: "siren",
15391
- name: "Emergency Siren",
15392
- icon: "🚑"
15377
+ name: "Emergency Siren"
15393
15378
  },
15394
15379
  {
15395
15380
  id: "fire",
15396
- name: "Fire / Smoke",
15397
- icon: "🔥"
15381
+ name: "Fire / Smoke"
15398
15382
  },
15399
15383
  {
15400
15384
  id: "water",
15401
- name: "Water",
15402
- icon: "💧"
15385
+ name: "Water"
15403
15386
  },
15404
15387
  {
15405
15388
  id: "wind",
15406
- name: "Wind / Weather",
15407
- icon: "🌬️"
15389
+ name: "Wind / Weather"
15408
15390
  },
15409
15391
  {
15410
15392
  id: "door",
15411
- name: "Door",
15412
- icon: "🚪"
15393
+ name: "Door"
15413
15394
  },
15414
15395
  {
15415
15396
  id: "footsteps",
15416
- name: "Footsteps",
15417
- icon: "👣"
15397
+ name: "Footsteps"
15418
15398
  },
15419
15399
  {
15420
15400
  id: "crowd",
15421
- name: "Crowd / Chatter",
15422
- icon: "👥"
15401
+ name: "Crowd / Chatter"
15423
15402
  },
15424
15403
  {
15425
15404
  id: "telephone",
15426
- name: "Telephone",
15427
- icon: "📞"
15405
+ name: "Telephone"
15428
15406
  },
15429
15407
  {
15430
15408
  id: "engine",
15431
- name: "Engine / Motor",
15432
- icon: "⚙️"
15409
+ name: "Engine / Motor"
15433
15410
  },
15434
15411
  {
15435
15412
  id: "tools",
15436
- name: "Tools / Construction",
15437
- icon: "🔨"
15413
+ name: "Tools / Construction"
15438
15414
  },
15439
15415
  {
15440
15416
  id: "silence",
15441
- name: "Silence",
15442
- icon: "🤫"
15417
+ name: "Silence"
15443
15418
  }
15444
15419
  ];
15445
15420
  var YAMNET_TO_MACRO = {
@@ -18492,7 +18467,7 @@ var TrackEnvelopeSchema = object({
18492
18467
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
18493
18468
  * keeps every scalar the list surfaces actually render (ids, class(es),
18494
18469
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
18495
- * zonesVisited, bestEventId, envelope, hasFace, hasRider) and returns `positions` /
18470
+ * zonesVisited, bestEventId, envelope, hasFace, hasEmbeddedFace, hasRider) and returns `positions` /
18496
18471
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
18497
18472
  * `getTrack`. Mirrors the event-store `projection` convention
18498
18473
  * (`getObjectEvents` et al.).
@@ -18787,6 +18762,35 @@ var TrackSchema = object({
18787
18762
  */
18788
18763
  hasFace: boolean().optional(),
18789
18764
  /**
18765
+ * This track has a face row IN THE GALLERY: a crop **and** an embedding — a
18766
+ * face an operator could ASSIGN to an identity.
18767
+ *
18768
+ * The STRICT twin of {@link hasFace}, and the pair only earns its keep
18769
+ * because the two disagree. `hasFace` is stamped at the TOP of the face
18770
+ * branch, before every gate, and means no more than "a face detector produced
18771
+ * a face detail". This one is stamped at the single moment the gallery row
18772
+ * LANDS — after `FaceRecognizer.onTrackEnd` successfully persists it, i.e.
18773
+ * past the embedding-magnitude verdict, the `minFacePx` detection gate, the
18774
+ * candidate gate, the imageless-track drop (no crop was ever captured) and
18775
+ * the crop-store drop. Everything between the detector and that insert can
18776
+ * legitimately refuse the face, so a flag written any earlier promises the
18777
+ * operator something to assign and delivers nothing.
18778
+ *
18779
+ * **Independent of recognition.** A face collected but never auto-matched is
18780
+ * still assignable — it is in fact the face an operator most wants to reach —
18781
+ * so this is NOT gated on `recognizedIdentityId`. Recognition lands in
18782
+ * `subLabel`; this says only that the raw material exists.
18783
+ *
18784
+ * **Set once, never cleared.** A track that produced a gallery row produced
18785
+ * one; deleting the row later is the gallery's business, not this flag's.
18786
+ *
18787
+ * **Absent ≠ false**, the same rule as {@link hasFace}: every row written
18788
+ * before the column omits it, and so does every server that predates the
18789
+ * field. A consumer must test `=== true` and render nothing otherwise —
18790
+ * never infer "no assignable face".
18791
+ */
18792
+ hasEmbeddedFace: boolean().optional(),
18793
+ /**
18790
18794
  * This subject CONTAINS a folded rider — a person the rider-pairing step
18791
18795
  * ([D34](../decisions/adr-0034.md)) removed from the frame BEFORE the tracker,
18792
18796
  * so the passage is tracked once and as a VEHICLE.
@@ -20729,6 +20733,32 @@ var DetailResultSchema = object({
20729
20733
  /** FRAME-space bbox (already mapped back from crop space). */
20730
20734
  bbox: NativeCropBboxSchema.optional(),
20731
20735
  embedding: string().optional(),
20736
+ /**
20737
+ * L2 magnitude of the RAW pre-normalization embedding. Additive and optional
20738
+ * so an older runner that does not send it degrades to "unmeasurable", which
20739
+ * the consuming gate treats as accept — never as reject.
20740
+ */
20741
+ embeddingMagnitude: number().optional(),
20742
+ /**
20743
+ * Model that produced `embedding` — the SAME contract `labelModelId` has for
20744
+ * `label`, and for the same reason: the producing node is the only side that
20745
+ * knows which model actually ran, and re-deriving it downstream is exactly
20746
+ * the inference this model exists to forbid.
20747
+ *
20748
+ * Additive and optional, so a runner that predates it degrades to
20749
+ * "unmeasurable" rather than to a wrong answer — the consumer falls back to
20750
+ * its previous assumption instead of refusing.
20751
+ *
20752
+ * Its absence was not cosmetic. `FaceRecognizer` needs the probe's model id
20753
+ * to use `face-matcher`'s same-model gate (`sample.modelId !== probe.modelId`);
20754
+ * with nothing on the wire it took the id from the GALLERY, so the gate
20755
+ * compared the gallery against itself and could never fire. That turned the
20756
+ * one guard protecting the face feature space into a tautology, and it is
20757
+ * why swapping `face-embedding`'s model would NOT have stranded the enrolled
20758
+ * gallery as the catalog comment predicts — it would have scored the new
20759
+ * model's vectors against the old model's samples in one cosine space.
20760
+ */
20761
+ embeddingModelId: string().optional(),
20732
20762
  label: string().optional(),
20733
20763
  /**
20734
20764
  * The tier `label` occupies, copied VERBATIM from the producing step's
@@ -22691,6 +22721,7 @@ var snapshotCapability = {
22691
22721
  isBattery: boolean(),
22692
22722
  reason: _enum([
22693
22723
  "disabled",
22724
+ "offline",
22694
22725
  "sleeping",
22695
22726
  "unreachable",
22696
22727
  "waking"
@@ -22780,9 +22811,17 @@ targets: array(object({
22780
22811
  sleeping: boolean(),
22781
22812
  /** Current device state rendered over the cached frame. State images
22782
22813
  * remain authoritative even when their photographic background is
22783
- * old; null means the link must carry a current camera frame. */
22814
+ * old; null means the link must carry a current camera frame.
22815
+ *
22816
+ * **This is the ONLY source a surface may derive an inactive tile
22817
+ * from.** A client that reads a device's `online`/`disabled` flags and
22818
+ * draws its own verdict will disagree with the pixels it is painting
22819
+ * — the frame comes from here, and so must the sentence over it
22820
+ * (D190). Pair with `ageMs` for the age of the photograph behind the
22821
+ * state. */
22784
22822
  stateReason: _enum([
22785
22823
  "disabled",
22824
+ "offline",
22786
22825
  "sleeping",
22787
22826
  "unreachable",
22788
22827
  "waking"
@@ -40776,7 +40815,6 @@ var NC_AUDIO_DEFAULTS = {
40776
40815
  hitPercent: 60,
40777
40816
  samplingSeconds: 10
40778
40817
  };
40779
- new Map(AUDIO_MACRO_LABELS.flatMap((macro) => macro.icon === void 0 ? [] : [[macro.id, macro.icon]]));
40780
40818
  new Set(["devices", "classes"]);
40781
40819
  NC_AUDIO_DEFAULTS.hitPercent, NC_AUDIO_DEFAULTS.samplingSeconds;
40782
40820
  var WEEKDAY_TO_DAY = {
@@ -41070,6 +41108,39 @@ TimelapseRuleInputSchema.extend({
41070
41108
  createdAt: number(),
41071
41109
  updatedAt: number()
41072
41110
  });
41111
+ Object.freeze(Object.fromEntries([{
41112
+ stepId: "face-embedding",
41113
+ label: "Face recognition model",
41114
+ defaultModelId: "arcface-r100",
41115
+ indexName: "the enrolled face gallery",
41116
+ options: [
41117
+ {
41118
+ id: "arcface-r100",
41119
+ label: "ArcFace (ResNet34)"
41120
+ },
41121
+ {
41122
+ id: "auraface-r100",
41123
+ label: "AuraFace R100"
41124
+ },
41125
+ {
41126
+ id: "inception-resnet-v1",
41127
+ label: "Inception ResNet V1"
41128
+ }
41129
+ ]
41130
+ }, {
41131
+ stepId: "clip-embedding",
41132
+ label: "Semantic search model",
41133
+ defaultModelId: "mobileclip-s1",
41134
+ indexName: "the object semantic-search index",
41135
+ options: [{
41136
+ id: "mobileclip-s1",
41137
+ label: "MobileCLIP S1"
41138
+ }, {
41139
+ id: "mobileclip-s2",
41140
+ label: "MobileCLIP S2"
41141
+ }]
41142
+ }].map((s) => [s.stepId, s.defaultModelId])));
41143
+ string().min(1);
41073
41144
  object({
41074
41145
  /**
41075
41146
  * Fraction of the box's own size added on EACH side before cutting.
@@ -41090,10 +41161,21 @@ object({
41090
41161
  */
41091
41162
  square: boolean()
41092
41163
  });
41093
- ({
41164
+ /**
41165
+ * The convention in force when nobody has configured one — byte-for-byte the
41166
+ * behaviour of the pre-unification LIVE path (`DETAIL_CROP_PADDING_RATIO`).
41167
+ */
41168
+ var DEFAULT_DETAIL_CROP_CONVENTION = {
41094
41169
  paddingRatio: .15,
41095
41170
  square: false
41096
- }).paddingRatio;
41171
+ };
41172
+ Object.freeze({
41173
+ x: 0,
41174
+ y: 0,
41175
+ w: 1,
41176
+ h: 1
41177
+ });
41178
+ DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio;
41097
41179
  /**
41098
41180
  * WHICH delivered frames the decode worker retains a native copy of.
41099
41181
  *
@@ -41251,6 +41333,37 @@ var OsdBindingsStore = class {
41251
41333
  }
41252
41334
  };
41253
41335
  //#endregion
41336
+ //#region src/bound-index.ts
41337
+ /**
41338
+ * The durable index of cameras that have at least one OSD binding.
41339
+ *
41340
+ * WHY IT EXISTS. The render loop's work list used to be DERIVED at boot: list
41341
+ * every device in the directory, read each one's binding block, keep the ones
41342
+ * that answered non-empty. Every one of those reads is a UDS round-trip to the
41343
+ * settings store, and they ran one at a time. Measured on the boot of
41344
+ * 2026-08-19: 976 devices × 559 ms under boot load = 546 s in which
41345
+ * `OsdManager.start()` had not returned — and `start()` was awaited inside
41346
+ * `onInitialize()`, so for nine minutes the `osd-manager` and
41347
+ * `addon-pages-source` capabilities did not exist ANYWHERE in the cluster
41348
+ * (docs/architecture/addon-lifecycle.md). The answer it took nine minutes to
41349
+ * compute was `boundDevices = 0`.
41350
+ *
41351
+ * Filtering to cameras and fanning the reads out makes that scan cheap, but it
41352
+ * is still a scan. The index is the actual fix: the work list is WRITTEN where
41353
+ * bindings are written, so boot reads one key and is done in milliseconds. The
41354
+ * scan stays, in the background, as the reconcile — because an index nothing
41355
+ * checks is an index that eventually lies.
41356
+ *
41357
+ * The value is the SET of bound camera ids, serialised as a sorted array.
41358
+ * `null` is not "no cameras are bound", it is "this hub has never written the
41359
+ * index" — the two must stay distinguishable, because the first is authoritative
41360
+ * and the second means seed it from a scan. An empty ARRAY is the authoritative
41361
+ * "nothing is bound".
41362
+ */
41363
+ /** Store key inside the addon's own (global) store. */
41364
+ var OSD_BOUND_INDEX_KEY = "boundDeviceIds";
41365
+ var OsdBoundIndexSchema = array(number().int().positive()).nullable();
41366
+ //#endregion
41254
41367
  //#region src/gate.ts
41255
41368
  /**
41256
41369
  * The display gate — "should this slot show its value right now?".
@@ -41327,6 +41440,27 @@ function evaluateGate(input) {
41327
41440
  return OPEN;
41328
41441
  }
41329
41442
  //#endregion
41443
+ //#region src/lane-pool.ts
41444
+ /**
41445
+ * Apply `mapper` to every item with at most `limit` in flight at once.
41446
+ *
41447
+ * A rejecting mapper rejects the whole call (same contract as `Promise.all`);
41448
+ * a caller that must not fail the sweep for one bad unit catches INSIDE the
41449
+ * mapper — which is what every caller in this package does, because a unit that
41450
+ * threw must never be the reason the next camera is not reconciled.
41451
+ */
41452
+ async function mapWithConcurrency(items, limit, mapper) {
41453
+ const results = new Array(items.length);
41454
+ if (items.length === 0) return results;
41455
+ const lanes = Math.max(1, Math.min(Math.trunc(limit), items.length));
41456
+ const entries = items.entries();
41457
+ const lane = async () => {
41458
+ for (const [index, item] of entries) results[index] = await mapper(item, index);
41459
+ };
41460
+ await Promise.all(Array.from({ length: lanes }, () => lane()));
41461
+ return results;
41462
+ }
41463
+ //#endregion
41330
41464
  //#region src/format.ts
41331
41465
  /** Tokens `formatClock` understands. Anything else in the pattern is literal. */
41332
41466
  var CLOCK_TOKENS = [
@@ -41659,31 +41793,61 @@ var OsdManager = class {
41659
41793
  recognitionGeneration = /* @__PURE__ */ new Map();
41660
41794
  recognitionInvalidatedAt = /* @__PURE__ */ new Map();
41661
41795
  timer = null;
41796
+ /** The background reconcile, while one is running. Held so a second arming
41797
+ * cannot overlap the first — two concurrent passes would race on the index
41798
+ * write and could persist the older of the two answers. */
41799
+ reconcile = null;
41662
41800
  constructor(deps) {
41663
41801
  this.deps = deps;
41664
41802
  this.logger = deps.logger;
41665
41803
  this.now = deps.now ?? (() => Date.now());
41666
41804
  }
41667
41805
  /**
41668
- * Learn which cameras have bindings, then start ticking. A camera with no
41669
- * binding costs nothing — it is never in `boundDevices`, so the loop
41670
- * never reads its state and never touches its firmware.
41806
+ * Read the durable work list, start ticking, and arm the reconcile BEHIND
41807
+ * both.
41808
+ *
41809
+ * This used to derive the work list first: list every device, read every
41810
+ * device's binding block, one at a time. On 2026-08-19 that was 976 devices ×
41811
+ * 559 ms = 546 s, awaited inside `onInitialize`, so every capability this
41812
+ * addon declares was absent cluster-wide for nine minutes — and the answer was
41813
+ * `boundDevices = 0`. A camera with no binding still costs nothing at runtime;
41814
+ * what it used to cost was the boot.
41815
+ *
41816
+ * The scan did not go away, it moved: it is the reconcile, it runs in the
41817
+ * background, and it is the only thing that keeps the index from silently
41818
+ * lying.
41671
41819
  */
41672
41820
  async start(tickMs) {
41673
- await this.reloadBoundDevices();
41821
+ const stored = await this.readBoundIndex();
41822
+ if (stored !== null) {
41823
+ this.boundDevices.clear();
41824
+ for (const deviceId of stored) this.boundDevices.add(deviceId);
41825
+ }
41674
41826
  this.timer = setInterval(() => {
41675
41827
  this.renderAllBound();
41676
41828
  }, tickMs);
41677
41829
  this.logger.info("osd-manager: render loop started", { meta: {
41678
41830
  tickMs,
41679
- boundDevices: this.boundDevices.size
41831
+ boundDevices: this.boundDevices.size,
41832
+ source: stored === null ? "seed" : "durable-index"
41680
41833
  } });
41834
+ this.armReconcile(stored === null ? "seed" : "reconcile");
41681
41835
  }
41682
41836
  stop() {
41683
41837
  if (this.timer !== null) clearInterval(this.timer);
41684
41838
  this.timer = null;
41685
41839
  this.lastWritten.clear();
41686
41840
  }
41841
+ /** Resolves when the armed reconcile has settled. Tests await it; production
41842
+ * never does — that is the entire point of arming it. */
41843
+ async settleReconcile() {
41844
+ await this.reconcile;
41845
+ }
41846
+ /** Every camera currently on the work list, sorted — the value persisted as
41847
+ * the durable index. */
41848
+ get boundDeviceIds() {
41849
+ return [...this.boundDevices].sort((a, b) => a - b);
41850
+ }
41687
41851
  /**
41688
41852
  * Keep recognition overlays event-fresh. The persisted-track hydration below
41689
41853
  * remains the restart/cold-start authority; lifecycle events only advance it.
@@ -41715,19 +41879,124 @@ var OsdManager = class {
41715
41879
  });
41716
41880
  }
41717
41881
  /**
41718
- * Reconcile the work list against the store rather than trusting an event
41719
- * to have arrived (D8). Cheap: one settings read per camera, on the tick
41720
- * cadence, not per frame.
41882
+ * Reconcile the durable index against the store rather than trusting it to be
41883
+ * right (D8 an index nothing checks is an index that eventually lies).
41884
+ *
41885
+ * Background work, and BOUNDED: cameras only, four reads in flight. Two rules
41886
+ * keep it from destroying work:
41887
+ *
41888
+ * - a binding read that FAILED decides nothing — the camera keeps whatever the
41889
+ * index already said (D49);
41890
+ * - a camera absent from the listing is left alone. The device restore pass
41891
+ * runs for minutes after boot (D167), so "not published yet" and "deleted"
41892
+ * look identical from here, and only one of them may unbind a camera. A
41893
+ * real removal comes from `clearSlotBinding`, which is authoritative.
41894
+ *
41895
+ * Every correction is logged with its `deviceId`, because a silent repair is
41896
+ * indistinguishable from an index that was right all along.
41897
+ */
41898
+ async reconcileBoundDevices(mode = "reconcile") {
41899
+ const cameras = await this.deps.listCameras();
41900
+ const probes = await mapWithConcurrency(cameras, this.deps.reconcileConcurrency ?? 4, async (camera) => ({
41901
+ deviceId: camera.id,
41902
+ bindings: await this.probeBindings(camera.id)
41903
+ }));
41904
+ const listed = new Set(cameras.map((camera) => camera.id));
41905
+ let unreadable = 0;
41906
+ const added = [];
41907
+ const removed = [];
41908
+ for (const probe of probes) {
41909
+ if (probe.bindings === null) {
41910
+ unreadable += 1;
41911
+ continue;
41912
+ }
41913
+ const bound = Object.keys(probe.bindings).length > 0;
41914
+ if (bound && !this.boundDevices.has(probe.deviceId)) {
41915
+ this.boundDevices.add(probe.deviceId);
41916
+ added.push(probe.deviceId);
41917
+ } else if (!bound && this.boundDevices.has(probe.deviceId)) {
41918
+ this.boundDevices.delete(probe.deviceId);
41919
+ removed.push(probe.deviceId);
41920
+ }
41921
+ }
41922
+ if (mode === "reconcile") {
41923
+ for (const deviceId of added) this.logger.warn("osd-manager: bound-device index was missing a camera that HAS bindings — corrected", {
41924
+ tags: { deviceId },
41925
+ meta: { correction: "added" }
41926
+ });
41927
+ for (const deviceId of removed) this.logger.warn("osd-manager: bound-device index claimed a camera with NO bindings — corrected", {
41928
+ tags: { deviceId },
41929
+ meta: { correction: "removed" }
41930
+ });
41931
+ }
41932
+ const notListed = [...this.boundDevices].filter((deviceId) => !listed.has(deviceId)).length;
41933
+ this.logger.info("osd-manager: bound-device reconcile complete", { meta: {
41934
+ mode,
41935
+ cameras: cameras.length,
41936
+ bound: this.boundDevices.size,
41937
+ added: added.length,
41938
+ removed: removed.length,
41939
+ unreadable,
41940
+ notListed
41941
+ } });
41942
+ if (mode === "seed" || added.length > 0 || removed.length > 0) await this.persistBoundIndex(null);
41943
+ }
41944
+ /**
41945
+ * Run the reconcile in the BACKGROUND, once at a time.
41946
+ *
41947
+ * Nothing on the boot path may await it — that await is what took the
41948
+ * capability graph down for nine minutes. A second arming while one is in
41949
+ * flight is dropped rather than queued: both passes would compute over the
41950
+ * same store and race on the index write.
41951
+ */
41952
+ armReconcile(mode) {
41953
+ if (this.reconcile !== null) return;
41954
+ this.reconcile = this.reconcileBoundDevices(mode).catch((err) => {
41955
+ this.logger.warn("osd-manager: bound-device reconcile failed — the index keeps its current contents", { meta: {
41956
+ mode,
41957
+ error: String(err)
41958
+ } });
41959
+ }).finally(() => {
41960
+ this.reconcile = null;
41961
+ });
41962
+ }
41963
+ /** The persisted index, or `null` when it has never been written (or could
41964
+ * not be read — both mean "derive it from a scan", which is safe). */
41965
+ async readBoundIndex() {
41966
+ try {
41967
+ return await this.deps.boundIndex.get();
41968
+ } catch (err) {
41969
+ this.logger.warn("osd-manager: bound-device index unreadable — seeding from a camera scan", { meta: { error: String(err) } });
41970
+ return null;
41971
+ }
41972
+ }
41973
+ /**
41974
+ * Persist the work list. Called from every place a binding is written, so the
41975
+ * index cannot drift from the bindings without the write that caused the drift
41976
+ * also failing — and that failure is logged, and repaired by the reconcile.
41721
41977
  */
41722
- async reloadBoundDevices() {
41723
- const devices = await this.safeListDevices();
41724
- const next = /* @__PURE__ */ new Set();
41725
- for (const device of devices) {
41726
- const bindings = await this.readBindings(device.id);
41727
- if (Object.keys(bindings).length > 0) next.add(device.id);
41978
+ async persistBoundIndex(deviceId) {
41979
+ try {
41980
+ await this.deps.boundIndex.set([...this.boundDeviceIds]);
41981
+ } catch (err) {
41982
+ this.logger.warn("osd-manager: bound-device index write failed — the next reconcile repairs it", {
41983
+ ...deviceId === null ? {} : { tags: { deviceId } },
41984
+ meta: { error: String(err) }
41985
+ });
41986
+ }
41987
+ }
41988
+ /** One camera's bindings, or `null` when the read FAILED. Distinct from an
41989
+ * empty map on purpose: only the second may unbind a camera (D49). */
41990
+ async probeBindings(deviceId) {
41991
+ try {
41992
+ return await this.deps.bindings(deviceId).get();
41993
+ } catch (err) {
41994
+ this.logger.warn("osd-manager: binding read failed during reconcile — this camera keeps its index entry", {
41995
+ tags: { deviceId },
41996
+ meta: { error: String(err) }
41997
+ });
41998
+ return null;
41728
41999
  }
41729
- this.boundDevices.clear();
41730
- for (const id of next) this.boundDevices.add(id);
41731
42000
  }
41732
42001
  getDeviceOsd = async ({ deviceId }) => {
41733
42002
  const status = await this.deps.readOsdStatus(deviceId);
@@ -41778,6 +42047,7 @@ var OsdManager = class {
41778
42047
  await this.assertSlotExists(deviceId, slotId);
41779
42048
  await this.deps.bindings(deviceId).update((prev) => withBinding(prev, slotId, binding));
41780
42049
  this.boundDevices.add(deviceId);
42050
+ await this.persistBoundIndex(deviceId);
41781
42051
  this.logger.info("osd-manager: slot binding saved", {
41782
42052
  tags: { deviceId },
41783
42053
  meta: {
@@ -41802,6 +42072,7 @@ var OsdManager = class {
41802
42072
  this.lastWritten.delete(cacheKey(deviceId, slotId));
41803
42073
  const remaining = await this.readBindings(deviceId);
41804
42074
  if (Object.keys(remaining).length === 0) this.boundDevices.delete(deviceId);
42075
+ await this.persistBoundIndex(deviceId);
41805
42076
  this.logger.info("osd-manager: slot binding cleared — slot keeps its last text", {
41806
42077
  tags: { deviceId },
41807
42078
  meta: { slotId }
@@ -41842,6 +42113,7 @@ var OsdManager = class {
41842
42113
  for (const key of [...this.lastWritten.keys()]) if (key.startsWith(`${targetDeviceId}:`)) this.lastWritten.delete(key);
41843
42114
  if (Object.keys(copiedBindings).length > 0) this.boundDevices.add(targetDeviceId);
41844
42115
  else this.boundDevices.delete(targetDeviceId);
42116
+ await this.persistBoundIndex(targetDeviceId);
41845
42117
  await this.renderDeviceInternal(targetDeviceId, null);
41846
42118
  this.logger.info("osd-manager: camera configuration copied", {
41847
42119
  tags: { deviceId: targetDeviceId },
@@ -42070,6 +42342,7 @@ var OsdManagerAddon = class extends BaseAddon {
42070
42342
  async onInitialize() {
42071
42343
  const ctx = this.ctx;
42072
42344
  const store = new OsdBindingsStore((deviceId, key, schema, fallback) => this.deviceState(deviceId, key, schema, fallback));
42345
+ const boundIndex = this.state(OSD_BOUND_INDEX_KEY, OsdBoundIndexSchema, null);
42073
42346
  const manager = new OsdManager({
42074
42347
  logger: ctx.logger,
42075
42348
  readOsdStatus: async (deviceId) => {
@@ -42106,6 +42379,15 @@ var OsdManagerAddon = class extends BaseAddon {
42106
42379
  name: d.name
42107
42380
  }));
42108
42381
  },
42382
+ listCameras: async () => {
42383
+ return (await ctx.api.deviceManager.listAll.query({
42384
+ isCamera: true,
42385
+ projection: "slim"
42386
+ })).map((d) => ({
42387
+ id: d.id,
42388
+ name: d.name
42389
+ }));
42390
+ },
42109
42391
  listDeviceCaps: async (deviceId) => {
42110
42392
  const bindings = await ctx.api.deviceManager.getBindings.query({ deviceId });
42111
42393
  return [...new Set(bindings.entries.map((e) => e.capName))];
@@ -42133,7 +42415,8 @@ var OsdManagerAddon = class extends BaseAddon {
42133
42415
  } } : {}
42134
42416
  };
42135
42417
  },
42136
- bindings: (deviceId) => store.forDevice(deviceId)
42418
+ bindings: (deviceId) => store.forDevice(deviceId),
42419
+ boundIndex
42137
42420
  });
42138
42421
  this.manager = manager;
42139
42422
  this.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
@@ -42145,7 +42428,9 @@ var OsdManagerAddon = class extends BaseAddon {
42145
42428
  this.subscribe({ category: EventCategory.PipelineAnalyticsPlateGalleryChanged }, (event) => {
42146
42429
  manager.onRecognitionGalleryChanged(event.data.deviceId);
42147
42430
  });
42148
- await manager.start(Math.max(1, this.config.tickSeconds) * 1e3);
42431
+ manager.start(Math.max(1, this.config.tickSeconds) * 1e3).catch((err) => {
42432
+ ctx.logger.error("osd-manager: render loop failed to start — no camera will be re-rendered", { meta: { error: err instanceof Error ? err.message : String(err) } });
42433
+ });
42149
42434
  const pagesProvider = {
42150
42435
  id: this.id,
42151
42436
  listPages: () => this.pages
@@ -1,5 +1,5 @@
1
1
  import { h as e, l as t, u as n, y as r } from "./_virtual_mf___mfe_internal__addon_osd_manager_page__loadShare__react__loadShare__.js-Bs1t18EM.mjs";
2
- import { o as i, s as a } from "./responsive-B6685e2G.mjs";
2
+ import { o as i, s as a } from "./responsive-7SgEY_K5.mjs";
3
3
  import { n as o, r as s, t as c } from "./_virtual_mf___mfe_internal__addon_osd_manager_page__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-DalfdIDw.mjs";
4
4
  var l = a("chevron-down", [["path", {
5
5
  d: "m6 9 6 6 6-6",
@@ -1,2 +1,2 @@
1
- import { n as e, t } from "./virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-7VFK3Vnq.mjs";
1
+ import { n as e, t } from "./virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-CFOj1Hwh.mjs";
2
2
  export { t as get, e as init };