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