@camstack/addon-osd-manager 0.1.3 → 0.1.4

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
@@ -24802,6 +24802,7 @@ var FaceInfoSchema = object({
24802
24802
  var FaceFilterEnum = _enum([
24803
24803
  "unassigned",
24804
24804
  "recognized",
24805
+ "identified",
24805
24806
  "all"
24806
24807
  ]);
24807
24808
  var MediaFileLiteSchema$1 = object({
@@ -24841,6 +24842,8 @@ var faceGalleryCapability = {
24841
24842
  auth: "admin"
24842
24843
  }),
24843
24844
  listRecentFaces: method(object({
24845
+ /** Restrict to one camera. Absent keeps the cluster-wide gallery view. */
24846
+ deviceId: number().int().optional(),
24844
24847
  limit: number().int().positive().optional(),
24845
24848
  filter: FaceFilterEnum.optional(),
24846
24849
  /**
@@ -27384,6 +27387,10 @@ var OsdSourceSchema = discriminatedUnion("kind", [
27384
27387
  capName: string().min(1).max(64),
27385
27388
  /** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
27386
27389
  valuePath: string().min(1).max(64)
27390
+ }),
27391
+ object({
27392
+ kind: literal("latest-recognition"),
27393
+ recognition: _enum(["person", "plate"])
27387
27394
  })
27388
27395
  ]);
27389
27396
  var OsdSlotBindingSchema = object({
@@ -27522,6 +27529,21 @@ var osdManagerCapability = {
27522
27529
  auth: "admin"
27523
27530
  }),
27524
27531
  /**
27532
+ * Replace one camera's binding configuration from another. Writable source
27533
+ * slots are mapped by ordinal onto writable target slots so different
27534
+ * provider slot ids do not make the copied configuration disappear.
27535
+ */
27536
+ copyDeviceConfiguration: method(object({
27537
+ sourceDeviceId: number().int(),
27538
+ targetDeviceId: number().int()
27539
+ }), object({
27540
+ copied: number().int().nonnegative(),
27541
+ skipped: number().int().nonnegative()
27542
+ }), {
27543
+ kind: "mutation",
27544
+ auth: "admin"
27545
+ }),
27546
+ /**
27525
27547
  * Render without writing. `binding` overrides the stored one so an
27526
27548
  * editor can preview an unsaved change. Mutation kind only to carry
27527
27549
  * the binding object safely; no side effects.
@@ -33706,6 +33728,12 @@ Object.freeze({
33706
33728
  addonId: null,
33707
33729
  access: "delete"
33708
33730
  },
33731
+ "osdManager.copyDeviceConfiguration": {
33732
+ capName: "osd-manager",
33733
+ capScope: "system",
33734
+ addonId: null,
33735
+ access: "create"
33736
+ },
33709
33737
  "osdManager.getConditionSupport": {
33710
33738
  capName: "osd-manager",
33711
33739
  capScope: "system",
@@ -36589,7 +36617,8 @@ var OsdBindingsStore = class {
36589
36617
  * Widening it means giving `evaluateGate` the facts to answer it — a
36590
36618
  * live detection subject would unlock `classes`, `zones`, `minConfidence`.
36591
36619
  */
36592
- var OSD_SUPPORTED_CONDITIONS = ["devices", "deviceState"];
36620
+ var OSD_SUPPORTED_CONDITIONS = ["deviceState"];
36621
+ var OSD_RUNTIME_CONDITIONS = ["devices", ...OSD_SUPPORTED_CONDITIONS];
36593
36622
  var OPEN = { open: true };
36594
36623
  /**
36595
36624
  * Which condition keys are set on `conditions` but outside the supported
@@ -36597,7 +36626,7 @@ var OPEN = { open: true };
36597
36626
  */
36598
36627
  function unsupportedConditionKeys(conditions) {
36599
36628
  if (conditions === void 0) return [];
36600
- const supported = new Set(OSD_SUPPORTED_CONDITIONS);
36629
+ const supported = new Set(OSD_RUNTIME_CONDITIONS);
36601
36630
  const present = [];
36602
36631
  for (const [key, value] of Object.entries(conditions)) {
36603
36632
  if (value === void 0) continue;
@@ -36840,6 +36869,10 @@ function resolveSource(binding, cameraDeviceId, facts) {
36840
36869
  const source = binding.source;
36841
36870
  if (source.kind === "static") return { value: { raw: source.text } };
36842
36871
  if (source.kind === "clock") return { value: { raw: formatClock(facts.atMs, source.pattern, source.timezone) } };
36872
+ if (source.kind === "latest-recognition") {
36873
+ const value = facts.recognitionByDevice.get(cameraDeviceId)?.[source.recognition];
36874
+ return value === void 0 ? { reason: `no recognized ${source.recognition} has been persisted for device ${cameraDeviceId}` } : { value: { raw: value } };
36875
+ }
36843
36876
  const deviceId = source.deviceId ?? cameraDeviceId;
36844
36877
  const deviceState = facts.stateByDevice.get(deviceId);
36845
36878
  if (deviceState === void 0) return { reason: `no state mirrored for device ${deviceId}` };
@@ -36963,6 +36996,10 @@ var OsdManager = class {
36963
36996
  lastWritten = /* @__PURE__ */ new Map();
36964
36997
  /** Cameras with at least one binding — the loop's work list. */
36965
36998
  boundDevices = /* @__PURE__ */ new Set();
36999
+ recognitions = /* @__PURE__ */ new Map();
37000
+ recognitionHydration = /* @__PURE__ */ new Map();
37001
+ recognitionGeneration = /* @__PURE__ */ new Map();
37002
+ recognitionInvalidatedAt = /* @__PURE__ */ new Map();
36966
37003
  timer = null;
36967
37004
  constructor(deps) {
36968
37005
  this.deps = deps;
@@ -36990,6 +37027,36 @@ var OsdManager = class {
36990
37027
  this.lastWritten.clear();
36991
37028
  }
36992
37029
  /**
37030
+ * Keep recognition overlays event-fresh. The persisted-track hydration below
37031
+ * remains the restart/cold-start authority; lifecycle events only advance it.
37032
+ */
37033
+ onTrackLifecycle(payload) {
37034
+ let changed = false;
37035
+ if (payload.label !== void 0 && (payload.bestClassName === "person" || payload.classes.includes("person"))) changed = this.mergeRecognition(payload.deviceId, "person", payload.label, payload.lastSeen, payload.firstSeen) || changed;
37036
+ if (payload.plateText !== void 0) changed = this.mergeRecognition(payload.deviceId, "plate", payload.plateText, payload.lastSeen, payload.firstSeen) || changed;
37037
+ if (!changed || !this.boundDevices.has(payload.deviceId)) return;
37038
+ this.renderDeviceInternal(payload.deviceId, null).catch((err) => {
37039
+ this.logger.warn("osd-manager: recognition-triggered render failed", {
37040
+ tags: { deviceId: payload.deviceId },
37041
+ meta: { error: String(err) }
37042
+ });
37043
+ });
37044
+ }
37045
+ /** Gallery rows are authoritative for corrections, assignments and deletion. */
37046
+ onRecognitionGalleryChanged(deviceId) {
37047
+ this.recognitionGeneration.set(deviceId, (this.recognitionGeneration.get(deviceId) ?? 0) + 1);
37048
+ this.recognitionInvalidatedAt.set(deviceId, this.now());
37049
+ this.recognitions.delete(deviceId);
37050
+ this.recognitionHydration.delete(deviceId);
37051
+ if (!this.boundDevices.has(deviceId)) return;
37052
+ this.renderDeviceInternal(deviceId, null).catch((err) => {
37053
+ this.logger.warn("osd-manager: gallery-triggered render failed", {
37054
+ tags: { deviceId },
37055
+ meta: { error: String(err) }
37056
+ });
37057
+ });
37058
+ }
37059
+ /**
36993
37060
  * Reconcile the work list against the store rather than trusting an event
36994
37061
  * to have arrived (D8). Cheap: one settings read per camera, on the tick
36995
37062
  * cadence, not per frame.
@@ -37083,6 +37150,54 @@ var OsdManager = class {
37083
37150
  });
37084
37151
  return { success: true };
37085
37152
  };
37153
+ copyDeviceConfiguration = async ({ sourceDeviceId, targetDeviceId }) => {
37154
+ if (sourceDeviceId === targetDeviceId) throw new Error("osd-manager: source and target camera must be different");
37155
+ const [sourceStatus, targetStatus, sourceBindings] = await Promise.all([
37156
+ this.deps.readOsdStatus(sourceDeviceId),
37157
+ this.deps.readOsdStatus(targetDeviceId),
37158
+ this.readBindings(sourceDeviceId)
37159
+ ]);
37160
+ if (sourceStatus === null) throw new Error(`osd-manager: source device ${sourceDeviceId} exposes no overlay slots`);
37161
+ if (targetStatus === null) throw new Error(`osd-manager: target device ${targetDeviceId} exposes no overlay slots`);
37162
+ const sourceSlots = sourceStatus.overlays.filter((slot) => slot.readOnly !== true && sourceBindings[slot.id] !== void 0);
37163
+ if (sourceSlots.length === 0) throw new Error(`osd-manager: source device ${sourceDeviceId} has no configured writable slots`);
37164
+ const targetSlots = targetStatus.overlays.filter((slot) => slot.readOnly !== true);
37165
+ const copiedBindings = {};
37166
+ const copyCount = Math.min(sourceSlots.length, targetSlots.length);
37167
+ for (let index = 0; index < copyCount; index += 1) {
37168
+ const sourceSlot = sourceSlots[index];
37169
+ const targetSlot = targetSlots[index];
37170
+ if (sourceSlot === void 0 || targetSlot === void 0) continue;
37171
+ const binding = sourceBindings[sourceSlot.id];
37172
+ if (binding === void 0) continue;
37173
+ const conditions = binding.conditions;
37174
+ const normalizedConditions = conditions === void 0 ? void 0 : (() => {
37175
+ const { devices: _cameraScope, ...rest } = conditions;
37176
+ return Object.keys(rest).length === 0 ? void 0 : rest;
37177
+ })();
37178
+ copiedBindings[targetSlot.id] = {
37179
+ ...binding,
37180
+ conditions: normalizedConditions
37181
+ };
37182
+ }
37183
+ await this.deps.bindings(targetDeviceId).set(copiedBindings);
37184
+ for (const key of [...this.lastWritten.keys()]) if (key.startsWith(`${targetDeviceId}:`)) this.lastWritten.delete(key);
37185
+ if (Object.keys(copiedBindings).length > 0) this.boundDevices.add(targetDeviceId);
37186
+ else this.boundDevices.delete(targetDeviceId);
37187
+ await this.renderDeviceInternal(targetDeviceId, null);
37188
+ this.logger.info("osd-manager: camera configuration copied", {
37189
+ tags: { deviceId: targetDeviceId },
37190
+ meta: {
37191
+ sourceDeviceId,
37192
+ copied: Object.keys(copiedBindings).length,
37193
+ skipped: Math.max(0, sourceSlots.length - targetSlots.length)
37194
+ }
37195
+ });
37196
+ return {
37197
+ copied: Object.keys(copiedBindings).length,
37198
+ skipped: Math.max(0, sourceSlots.length - targetSlots.length)
37199
+ };
37200
+ };
37086
37201
  previewSlot = async ({ deviceId, slotId, binding }) => {
37087
37202
  if (binding !== void 0) assertSupportedConditions(binding.conditions);
37088
37203
  const effective = binding ?? (await this.readBindings(deviceId))[slotId] ?? null;
@@ -37157,6 +37272,7 @@ var OsdManager = class {
37157
37272
  */
37158
37273
  async collectFacts(cameraDeviceId, bindings) {
37159
37274
  const wanted = new Set([cameraDeviceId]);
37275
+ const needsRecognition = Object.values(bindings).some((binding) => binding.source.kind === "latest-recognition");
37160
37276
  for (const binding of Object.values(bindings)) {
37161
37277
  if (binding.source.kind === "device-state" && binding.source.deviceId !== void 0) wanted.add(binding.source.deviceId);
37162
37278
  const gated = binding.conditions?.deviceState?.deviceId;
@@ -37175,12 +37291,59 @@ var OsdManager = class {
37175
37291
  meta: { error: String(err) }
37176
37292
  });
37177
37293
  }
37294
+ if (needsRecognition) await this.hydrateRecognitions(cameraDeviceId);
37295
+ const recognitionByDevice = /* @__PURE__ */ new Map();
37296
+ const known = this.recognitions.get(cameraDeviceId);
37297
+ if (known !== void 0) recognitionByDevice.set(cameraDeviceId, {
37298
+ ...known.person !== void 0 ? { person: known.person.text } : {},
37299
+ ...known.plate !== void 0 ? { plate: known.plate.text } : {}
37300
+ });
37178
37301
  return {
37179
37302
  atMs: this.now(),
37180
37303
  stateByDevice,
37304
+ recognitionByDevice,
37181
37305
  deviceState: (id) => gateState.get(id)
37182
37306
  };
37183
37307
  }
37308
+ async hydrateRecognitions(deviceId) {
37309
+ const existing = this.recognitionHydration.get(deviceId);
37310
+ if (existing !== void 0) return existing;
37311
+ const generation = this.recognitionGeneration.get(deviceId) ?? 0;
37312
+ const hydration = (async () => {
37313
+ try {
37314
+ const latest = await this.deps.readLatestRecognitions(deviceId);
37315
+ if ((this.recognitionGeneration.get(deviceId) ?? 0) !== generation) return;
37316
+ if (latest.person !== void 0) this.mergeRecognition(deviceId, "person", latest.person.text, latest.person.atMs);
37317
+ if (latest.plate !== void 0) this.mergeRecognition(deviceId, "plate", latest.plate.text, latest.plate.atMs);
37318
+ } catch (err) {
37319
+ this.logger.warn("osd-manager: recognition history hydration failed", {
37320
+ tags: { deviceId },
37321
+ meta: { error: String(err) }
37322
+ });
37323
+ this.recognitionHydration.delete(deviceId);
37324
+ }
37325
+ })();
37326
+ this.recognitionHydration.set(deviceId, hydration);
37327
+ await hydration;
37328
+ }
37329
+ mergeRecognition(deviceId, kind, text, atMs, trackFirstSeen) {
37330
+ const normalized = text.trim();
37331
+ if (normalized === "") return false;
37332
+ const invalidatedAt = this.recognitionInvalidatedAt.get(deviceId);
37333
+ if (trackFirstSeen !== void 0 && invalidatedAt !== void 0 && trackFirstSeen <= invalidatedAt) return false;
37334
+ const current = this.recognitions.get(deviceId) ?? {};
37335
+ const previous = current[kind];
37336
+ if (previous !== void 0 && previous.atMs > atMs) return false;
37337
+ if (previous?.atMs === atMs && previous.text === normalized) return false;
37338
+ this.recognitions.set(deviceId, {
37339
+ ...current,
37340
+ [kind]: {
37341
+ text: normalized,
37342
+ atMs
37343
+ }
37344
+ });
37345
+ return true;
37346
+ }
37184
37347
  async readBindings(deviceId) {
37185
37348
  try {
37186
37349
  return await this.deps.bindings(deviceId).get();
@@ -37240,7 +37403,8 @@ var OsdManagerAddon = class extends BaseAddon {
37240
37403
  icon: "monitor",
37241
37404
  path: "/addon/osd-manager",
37242
37405
  remoteName: "addon_osd_manager_page",
37243
- bundle: "remoteEntry.js"
37406
+ bundle: "remoteEntry.js",
37407
+ section: "detection"
37244
37408
  }];
37245
37409
  constructor() {
37246
37410
  super({ ...DEFAULTS });
@@ -37288,9 +37452,41 @@ var OsdManagerAddon = class extends BaseAddon {
37288
37452
  const bindings = await ctx.api.deviceManager.getBindings.query({ deviceId });
37289
37453
  return [...new Set(bindings.entries.map((e) => e.capName))];
37290
37454
  },
37455
+ readLatestRecognitions: async (deviceId) => {
37456
+ const [faces, plates] = await Promise.all([ctx.api.faceGallery.listRecentFaces.query({
37457
+ deviceId,
37458
+ limit: 1,
37459
+ filter: "identified",
37460
+ includeCrops: false
37461
+ }), ctx.api.plateGallery.listPlates.query({
37462
+ deviceId,
37463
+ limit: 1
37464
+ })]);
37465
+ const face = faces[0];
37466
+ const plate = plates[0];
37467
+ return {
37468
+ ...face?.identityName !== void 0 ? { person: {
37469
+ text: face.identityName,
37470
+ atMs: face.timestamp
37471
+ } } : {},
37472
+ ...plate !== void 0 ? { plate: {
37473
+ text: plate.text,
37474
+ atMs: plate.timestamp
37475
+ } } : {}
37476
+ };
37477
+ },
37291
37478
  bindings: (deviceId) => store.forDevice(deviceId)
37292
37479
  });
37293
37480
  this.manager = manager;
37481
+ this.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
37482
+ manager.onTrackLifecycle(event.data);
37483
+ });
37484
+ this.subscribe({ category: EventCategory.PipelineAnalyticsFaceGalleryChanged }, (event) => {
37485
+ manager.onRecognitionGalleryChanged(event.data.deviceId);
37486
+ });
37487
+ this.subscribe({ category: EventCategory.PipelineAnalyticsPlateGalleryChanged }, (event) => {
37488
+ manager.onRecognitionGalleryChanged(event.data.deviceId);
37489
+ });
37294
37490
  await manager.start(Math.max(1, this.config.tickSeconds) * 1e3);
37295
37491
  const pagesProvider = {
37296
37492
  id: this.id,
package/dist/index.mjs CHANGED
@@ -24798,6 +24798,7 @@ var FaceInfoSchema = object({
24798
24798
  var FaceFilterEnum = _enum([
24799
24799
  "unassigned",
24800
24800
  "recognized",
24801
+ "identified",
24801
24802
  "all"
24802
24803
  ]);
24803
24804
  var MediaFileLiteSchema$1 = object({
@@ -24837,6 +24838,8 @@ var faceGalleryCapability = {
24837
24838
  auth: "admin"
24838
24839
  }),
24839
24840
  listRecentFaces: method(object({
24841
+ /** Restrict to one camera. Absent keeps the cluster-wide gallery view. */
24842
+ deviceId: number().int().optional(),
24840
24843
  limit: number().int().positive().optional(),
24841
24844
  filter: FaceFilterEnum.optional(),
24842
24845
  /**
@@ -27380,6 +27383,10 @@ var OsdSourceSchema = discriminatedUnion("kind", [
27380
27383
  capName: string().min(1).max(64),
27381
27384
  /** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
27382
27385
  valuePath: string().min(1).max(64)
27386
+ }),
27387
+ object({
27388
+ kind: literal("latest-recognition"),
27389
+ recognition: _enum(["person", "plate"])
27383
27390
  })
27384
27391
  ]);
27385
27392
  var OsdSlotBindingSchema = object({
@@ -27518,6 +27525,21 @@ var osdManagerCapability = {
27518
27525
  auth: "admin"
27519
27526
  }),
27520
27527
  /**
27528
+ * Replace one camera's binding configuration from another. Writable source
27529
+ * slots are mapped by ordinal onto writable target slots so different
27530
+ * provider slot ids do not make the copied configuration disappear.
27531
+ */
27532
+ copyDeviceConfiguration: method(object({
27533
+ sourceDeviceId: number().int(),
27534
+ targetDeviceId: number().int()
27535
+ }), object({
27536
+ copied: number().int().nonnegative(),
27537
+ skipped: number().int().nonnegative()
27538
+ }), {
27539
+ kind: "mutation",
27540
+ auth: "admin"
27541
+ }),
27542
+ /**
27521
27543
  * Render without writing. `binding` overrides the stored one so an
27522
27544
  * editor can preview an unsaved change. Mutation kind only to carry
27523
27545
  * the binding object safely; no side effects.
@@ -33702,6 +33724,12 @@ Object.freeze({
33702
33724
  addonId: null,
33703
33725
  access: "delete"
33704
33726
  },
33727
+ "osdManager.copyDeviceConfiguration": {
33728
+ capName: "osd-manager",
33729
+ capScope: "system",
33730
+ addonId: null,
33731
+ access: "create"
33732
+ },
33705
33733
  "osdManager.getConditionSupport": {
33706
33734
  capName: "osd-manager",
33707
33735
  capScope: "system",
@@ -36585,7 +36613,8 @@ var OsdBindingsStore = class {
36585
36613
  * Widening it means giving `evaluateGate` the facts to answer it — a
36586
36614
  * live detection subject would unlock `classes`, `zones`, `minConfidence`.
36587
36615
  */
36588
- var OSD_SUPPORTED_CONDITIONS = ["devices", "deviceState"];
36616
+ var OSD_SUPPORTED_CONDITIONS = ["deviceState"];
36617
+ var OSD_RUNTIME_CONDITIONS = ["devices", ...OSD_SUPPORTED_CONDITIONS];
36589
36618
  var OPEN = { open: true };
36590
36619
  /**
36591
36620
  * Which condition keys are set on `conditions` but outside the supported
@@ -36593,7 +36622,7 @@ var OPEN = { open: true };
36593
36622
  */
36594
36623
  function unsupportedConditionKeys(conditions) {
36595
36624
  if (conditions === void 0) return [];
36596
- const supported = new Set(OSD_SUPPORTED_CONDITIONS);
36625
+ const supported = new Set(OSD_RUNTIME_CONDITIONS);
36597
36626
  const present = [];
36598
36627
  for (const [key, value] of Object.entries(conditions)) {
36599
36628
  if (value === void 0) continue;
@@ -36836,6 +36865,10 @@ function resolveSource(binding, cameraDeviceId, facts) {
36836
36865
  const source = binding.source;
36837
36866
  if (source.kind === "static") return { value: { raw: source.text } };
36838
36867
  if (source.kind === "clock") return { value: { raw: formatClock(facts.atMs, source.pattern, source.timezone) } };
36868
+ if (source.kind === "latest-recognition") {
36869
+ const value = facts.recognitionByDevice.get(cameraDeviceId)?.[source.recognition];
36870
+ return value === void 0 ? { reason: `no recognized ${source.recognition} has been persisted for device ${cameraDeviceId}` } : { value: { raw: value } };
36871
+ }
36839
36872
  const deviceId = source.deviceId ?? cameraDeviceId;
36840
36873
  const deviceState = facts.stateByDevice.get(deviceId);
36841
36874
  if (deviceState === void 0) return { reason: `no state mirrored for device ${deviceId}` };
@@ -36959,6 +36992,10 @@ var OsdManager = class {
36959
36992
  lastWritten = /* @__PURE__ */ new Map();
36960
36993
  /** Cameras with at least one binding — the loop's work list. */
36961
36994
  boundDevices = /* @__PURE__ */ new Set();
36995
+ recognitions = /* @__PURE__ */ new Map();
36996
+ recognitionHydration = /* @__PURE__ */ new Map();
36997
+ recognitionGeneration = /* @__PURE__ */ new Map();
36998
+ recognitionInvalidatedAt = /* @__PURE__ */ new Map();
36962
36999
  timer = null;
36963
37000
  constructor(deps) {
36964
37001
  this.deps = deps;
@@ -36986,6 +37023,36 @@ var OsdManager = class {
36986
37023
  this.lastWritten.clear();
36987
37024
  }
36988
37025
  /**
37026
+ * Keep recognition overlays event-fresh. The persisted-track hydration below
37027
+ * remains the restart/cold-start authority; lifecycle events only advance it.
37028
+ */
37029
+ onTrackLifecycle(payload) {
37030
+ let changed = false;
37031
+ if (payload.label !== void 0 && (payload.bestClassName === "person" || payload.classes.includes("person"))) changed = this.mergeRecognition(payload.deviceId, "person", payload.label, payload.lastSeen, payload.firstSeen) || changed;
37032
+ if (payload.plateText !== void 0) changed = this.mergeRecognition(payload.deviceId, "plate", payload.plateText, payload.lastSeen, payload.firstSeen) || changed;
37033
+ if (!changed || !this.boundDevices.has(payload.deviceId)) return;
37034
+ this.renderDeviceInternal(payload.deviceId, null).catch((err) => {
37035
+ this.logger.warn("osd-manager: recognition-triggered render failed", {
37036
+ tags: { deviceId: payload.deviceId },
37037
+ meta: { error: String(err) }
37038
+ });
37039
+ });
37040
+ }
37041
+ /** Gallery rows are authoritative for corrections, assignments and deletion. */
37042
+ onRecognitionGalleryChanged(deviceId) {
37043
+ this.recognitionGeneration.set(deviceId, (this.recognitionGeneration.get(deviceId) ?? 0) + 1);
37044
+ this.recognitionInvalidatedAt.set(deviceId, this.now());
37045
+ this.recognitions.delete(deviceId);
37046
+ this.recognitionHydration.delete(deviceId);
37047
+ if (!this.boundDevices.has(deviceId)) return;
37048
+ this.renderDeviceInternal(deviceId, null).catch((err) => {
37049
+ this.logger.warn("osd-manager: gallery-triggered render failed", {
37050
+ tags: { deviceId },
37051
+ meta: { error: String(err) }
37052
+ });
37053
+ });
37054
+ }
37055
+ /**
36989
37056
  * Reconcile the work list against the store rather than trusting an event
36990
37057
  * to have arrived (D8). Cheap: one settings read per camera, on the tick
36991
37058
  * cadence, not per frame.
@@ -37079,6 +37146,54 @@ var OsdManager = class {
37079
37146
  });
37080
37147
  return { success: true };
37081
37148
  };
37149
+ copyDeviceConfiguration = async ({ sourceDeviceId, targetDeviceId }) => {
37150
+ if (sourceDeviceId === targetDeviceId) throw new Error("osd-manager: source and target camera must be different");
37151
+ const [sourceStatus, targetStatus, sourceBindings] = await Promise.all([
37152
+ this.deps.readOsdStatus(sourceDeviceId),
37153
+ this.deps.readOsdStatus(targetDeviceId),
37154
+ this.readBindings(sourceDeviceId)
37155
+ ]);
37156
+ if (sourceStatus === null) throw new Error(`osd-manager: source device ${sourceDeviceId} exposes no overlay slots`);
37157
+ if (targetStatus === null) throw new Error(`osd-manager: target device ${targetDeviceId} exposes no overlay slots`);
37158
+ const sourceSlots = sourceStatus.overlays.filter((slot) => slot.readOnly !== true && sourceBindings[slot.id] !== void 0);
37159
+ if (sourceSlots.length === 0) throw new Error(`osd-manager: source device ${sourceDeviceId} has no configured writable slots`);
37160
+ const targetSlots = targetStatus.overlays.filter((slot) => slot.readOnly !== true);
37161
+ const copiedBindings = {};
37162
+ const copyCount = Math.min(sourceSlots.length, targetSlots.length);
37163
+ for (let index = 0; index < copyCount; index += 1) {
37164
+ const sourceSlot = sourceSlots[index];
37165
+ const targetSlot = targetSlots[index];
37166
+ if (sourceSlot === void 0 || targetSlot === void 0) continue;
37167
+ const binding = sourceBindings[sourceSlot.id];
37168
+ if (binding === void 0) continue;
37169
+ const conditions = binding.conditions;
37170
+ const normalizedConditions = conditions === void 0 ? void 0 : (() => {
37171
+ const { devices: _cameraScope, ...rest } = conditions;
37172
+ return Object.keys(rest).length === 0 ? void 0 : rest;
37173
+ })();
37174
+ copiedBindings[targetSlot.id] = {
37175
+ ...binding,
37176
+ conditions: normalizedConditions
37177
+ };
37178
+ }
37179
+ await this.deps.bindings(targetDeviceId).set(copiedBindings);
37180
+ for (const key of [...this.lastWritten.keys()]) if (key.startsWith(`${targetDeviceId}:`)) this.lastWritten.delete(key);
37181
+ if (Object.keys(copiedBindings).length > 0) this.boundDevices.add(targetDeviceId);
37182
+ else this.boundDevices.delete(targetDeviceId);
37183
+ await this.renderDeviceInternal(targetDeviceId, null);
37184
+ this.logger.info("osd-manager: camera configuration copied", {
37185
+ tags: { deviceId: targetDeviceId },
37186
+ meta: {
37187
+ sourceDeviceId,
37188
+ copied: Object.keys(copiedBindings).length,
37189
+ skipped: Math.max(0, sourceSlots.length - targetSlots.length)
37190
+ }
37191
+ });
37192
+ return {
37193
+ copied: Object.keys(copiedBindings).length,
37194
+ skipped: Math.max(0, sourceSlots.length - targetSlots.length)
37195
+ };
37196
+ };
37082
37197
  previewSlot = async ({ deviceId, slotId, binding }) => {
37083
37198
  if (binding !== void 0) assertSupportedConditions(binding.conditions);
37084
37199
  const effective = binding ?? (await this.readBindings(deviceId))[slotId] ?? null;
@@ -37153,6 +37268,7 @@ var OsdManager = class {
37153
37268
  */
37154
37269
  async collectFacts(cameraDeviceId, bindings) {
37155
37270
  const wanted = new Set([cameraDeviceId]);
37271
+ const needsRecognition = Object.values(bindings).some((binding) => binding.source.kind === "latest-recognition");
37156
37272
  for (const binding of Object.values(bindings)) {
37157
37273
  if (binding.source.kind === "device-state" && binding.source.deviceId !== void 0) wanted.add(binding.source.deviceId);
37158
37274
  const gated = binding.conditions?.deviceState?.deviceId;
@@ -37171,12 +37287,59 @@ var OsdManager = class {
37171
37287
  meta: { error: String(err) }
37172
37288
  });
37173
37289
  }
37290
+ if (needsRecognition) await this.hydrateRecognitions(cameraDeviceId);
37291
+ const recognitionByDevice = /* @__PURE__ */ new Map();
37292
+ const known = this.recognitions.get(cameraDeviceId);
37293
+ if (known !== void 0) recognitionByDevice.set(cameraDeviceId, {
37294
+ ...known.person !== void 0 ? { person: known.person.text } : {},
37295
+ ...known.plate !== void 0 ? { plate: known.plate.text } : {}
37296
+ });
37174
37297
  return {
37175
37298
  atMs: this.now(),
37176
37299
  stateByDevice,
37300
+ recognitionByDevice,
37177
37301
  deviceState: (id) => gateState.get(id)
37178
37302
  };
37179
37303
  }
37304
+ async hydrateRecognitions(deviceId) {
37305
+ const existing = this.recognitionHydration.get(deviceId);
37306
+ if (existing !== void 0) return existing;
37307
+ const generation = this.recognitionGeneration.get(deviceId) ?? 0;
37308
+ const hydration = (async () => {
37309
+ try {
37310
+ const latest = await this.deps.readLatestRecognitions(deviceId);
37311
+ if ((this.recognitionGeneration.get(deviceId) ?? 0) !== generation) return;
37312
+ if (latest.person !== void 0) this.mergeRecognition(deviceId, "person", latest.person.text, latest.person.atMs);
37313
+ if (latest.plate !== void 0) this.mergeRecognition(deviceId, "plate", latest.plate.text, latest.plate.atMs);
37314
+ } catch (err) {
37315
+ this.logger.warn("osd-manager: recognition history hydration failed", {
37316
+ tags: { deviceId },
37317
+ meta: { error: String(err) }
37318
+ });
37319
+ this.recognitionHydration.delete(deviceId);
37320
+ }
37321
+ })();
37322
+ this.recognitionHydration.set(deviceId, hydration);
37323
+ await hydration;
37324
+ }
37325
+ mergeRecognition(deviceId, kind, text, atMs, trackFirstSeen) {
37326
+ const normalized = text.trim();
37327
+ if (normalized === "") return false;
37328
+ const invalidatedAt = this.recognitionInvalidatedAt.get(deviceId);
37329
+ if (trackFirstSeen !== void 0 && invalidatedAt !== void 0 && trackFirstSeen <= invalidatedAt) return false;
37330
+ const current = this.recognitions.get(deviceId) ?? {};
37331
+ const previous = current[kind];
37332
+ if (previous !== void 0 && previous.atMs > atMs) return false;
37333
+ if (previous?.atMs === atMs && previous.text === normalized) return false;
37334
+ this.recognitions.set(deviceId, {
37335
+ ...current,
37336
+ [kind]: {
37337
+ text: normalized,
37338
+ atMs
37339
+ }
37340
+ });
37341
+ return true;
37342
+ }
37180
37343
  async readBindings(deviceId) {
37181
37344
  try {
37182
37345
  return await this.deps.bindings(deviceId).get();
@@ -37236,7 +37399,8 @@ var OsdManagerAddon = class extends BaseAddon {
37236
37399
  icon: "monitor",
37237
37400
  path: "/addon/osd-manager",
37238
37401
  remoteName: "addon_osd_manager_page",
37239
- bundle: "remoteEntry.js"
37402
+ bundle: "remoteEntry.js",
37403
+ section: "detection"
37240
37404
  }];
37241
37405
  constructor() {
37242
37406
  super({ ...DEFAULTS });
@@ -37284,9 +37448,41 @@ var OsdManagerAddon = class extends BaseAddon {
37284
37448
  const bindings = await ctx.api.deviceManager.getBindings.query({ deviceId });
37285
37449
  return [...new Set(bindings.entries.map((e) => e.capName))];
37286
37450
  },
37451
+ readLatestRecognitions: async (deviceId) => {
37452
+ const [faces, plates] = await Promise.all([ctx.api.faceGallery.listRecentFaces.query({
37453
+ deviceId,
37454
+ limit: 1,
37455
+ filter: "identified",
37456
+ includeCrops: false
37457
+ }), ctx.api.plateGallery.listPlates.query({
37458
+ deviceId,
37459
+ limit: 1
37460
+ })]);
37461
+ const face = faces[0];
37462
+ const plate = plates[0];
37463
+ return {
37464
+ ...face?.identityName !== void 0 ? { person: {
37465
+ text: face.identityName,
37466
+ atMs: face.timestamp
37467
+ } } : {},
37468
+ ...plate !== void 0 ? { plate: {
37469
+ text: plate.text,
37470
+ atMs: plate.timestamp
37471
+ } } : {}
37472
+ };
37473
+ },
37287
37474
  bindings: (deviceId) => store.forDevice(deviceId)
37288
37475
  });
37289
37476
  this.manager = manager;
37477
+ this.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
37478
+ manager.onTrackLifecycle(event.data);
37479
+ });
37480
+ this.subscribe({ category: EventCategory.PipelineAnalyticsFaceGalleryChanged }, (event) => {
37481
+ manager.onRecognitionGalleryChanged(event.data.deviceId);
37482
+ });
37483
+ this.subscribe({ category: EventCategory.PipelineAnalyticsPlateGalleryChanged }, (event) => {
37484
+ manager.onRecognitionGalleryChanged(event.data.deviceId);
37485
+ });
37290
37486
  await manager.start(Math.max(1, this.config.tickSeconds) * 1e3);
37291
37487
  const pagesProvider = {
37292
37488
  id: this.id,
@@ -1,2 +1,2 @@
1
- import { n as e, t } from "./virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-Bx-1SwyE.mjs";
1
+ import { n as e, t } from "./virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_osd_manager_page__remoteEntry_js-CdG2yVd0.mjs";
2
2
  export { t as get, e as init };
@@ -2753,7 +2753,7 @@ async function rr(e) {
2753
2753
  }
2754
2754
  }
2755
2755
  async function ir() {
2756
- return tr ||= rr(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_osd_manager_page-BBrLl2CB.mjs")).catch((e) => {
2756
+ return tr ||= rr(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_osd_manager_page-CL-YG2or.mjs")).catch((e) => {
2757
2757
  throw tr = void 0, e;
2758
2758
  }), tr;
2759
2759
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@camstack/addon-osd-manager",
3
- "version": "0.1.3",
4
- "description": "Binds camera on-screen-display slots to live device state",
3
+ "version": "0.1.4",
4
+ "description": "Binds camera on-screen-display slots to live state and recognitions",
5
5
  "keywords": [
6
6
  "camstack",
7
7
  "addon",
@@ -33,7 +33,7 @@
33
33
  "category": "system",
34
34
  "name": "OSD Manager",
35
35
  "version": "0.1.0",
36
- "description": "Drives the text a camera burns into its own video. Each overlay slot the camera exposes can be bound to a live value any cap-keyed device state, a clock, or a literal — formatted with a template and shown only while a condition holds. Conditions use the notification centre's vocabulary. Writes nothing when the rendered text has not changed.",
36
+ "description": "Drives the text a camera burns into its own video. Each overlay slot can be bound to device state, the latest recognized person or plate, a clock, or a literal. Writes nothing when the rendered text has not changed.",
37
37
  "entry": "./dist/index.js",
38
38
  "icon": "assets/icon.svg",
39
39
  "color": "#f59e0b",
@@ -57,7 +57,8 @@
57
57
  "icon": "monitor",
58
58
  "path": "/addon/osd-manager",
59
59
  "remoteName": "addon_osd_manager_page",
60
- "bundle": "remoteEntry.js"
60
+ "bundle": "remoteEntry.js",
61
+ "section": "detection"
61
62
  }
62
63
  ]
63
64
  }