@camstack/addon-post-analysis 1.2.94 → 1.2.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6405,6 +6405,16 @@ var NcDeviceStateCache = class {
6405
6405
  }
6406
6406
  };
6407
6407
  //#endregion
6408
+ //#region src/notification-center/default-tap-url.ts
6409
+ /** The viewer's hosted base path under the hub. */
6410
+ var VIEWER_BASE_PATH = "/viewer/camstack";
6411
+ function defaultTapUrl(input) {
6412
+ if (input.systemEvent === true) return null;
6413
+ if (input.baseUrl === null || input.baseUrl.length === 0) return null;
6414
+ if (!Number.isInteger(input.deviceId) || input.deviceId <= 0) return null;
6415
+ return `${input.baseUrl.replace(/\/+$/, "")}${VIEWER_BASE_PATH}/camera/${String(input.deviceId)}?at=${String(input.timestampMs)}`;
6416
+ }
6417
+ //#endregion
6408
6418
  //#region src/shared/frame/box-drawer.ts
6409
6419
  var DEFAULT_COLOR = require_dist.DEFAULT_EVENT_COLOR;
6410
6420
  var DEFAULT_QUALITY = 80;
@@ -9262,12 +9272,19 @@ var NcDispatcher = class {
9262
9272
  }) : rendered;
9263
9273
  const attachments = [...await this.withArtifactUrls(entry, await this.resolveAttachments(entry)), ...frozenArtifactAttachments(entry)];
9264
9274
  const params = pickParams(entry.payload.params);
9275
+ const tapUrl = params.clickUrl === void 0 && this.deps.viewerUrlBase !== void 0 ? defaultTapUrl({
9276
+ deviceId: systemEvent !== void 0 ? 0 : subject.deviceId,
9277
+ timestampMs: subject.timestamp,
9278
+ baseUrl: await this.deps.viewerUrlBase().catch(() => null),
9279
+ systemEvent: systemEvent !== void 0
9280
+ }) : null;
9265
9281
  const actions = await this.mintActions(entry, target);
9266
9282
  return {
9267
9283
  body,
9268
9284
  title,
9269
9285
  priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
9270
9286
  ...params,
9287
+ ...tapUrl !== null ? { clickUrl: tapUrl } : {},
9271
9288
  tag: entry.payload.group?.key ?? entry.ruleId,
9272
9289
  ...systemEvent !== void 0 ? systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {} : { deviceId: subject.deviceId },
9273
9290
  ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
@@ -19148,7 +19165,8 @@ var NotificationCenter = class NotificationCenter {
19148
19165
  confirmGate: (input) => confirmGate.judge(input),
19149
19166
  confirmStats: () => confirmGate.stats()
19150
19167
  } : {},
19151
- releaseCooldown: (input) => this.releaseCooldown(input)
19168
+ releaseCooldown: (input) => this.releaseCooldown(input),
19169
+ ...deps.dispatcher.viewerUrlBase !== void 0 ? { viewerUrlBase: deps.dispatcher.viewerUrlBase } : {}
19152
19170
  });
19153
19171
  this.outbox = new NcOutbox({
19154
19172
  store: deps.store,
@@ -24856,6 +24874,188 @@ function clusterByEmbedding(items, threshold = .5) {
24856
24874
  })).toSorted((a, b) => b.size - a.size || b.cohesion - a.cohesion);
24857
24875
  }
24858
24876
  //#endregion
24877
+ //#region src/pipeline-analytics/pipeline/face-matcher.ts
24878
+ /**
24879
+ * Count enrolled samples per identity for probes of a matching model+dimension.
24880
+ * Only identities meeting `minIdentitySamples` are eligible auto-match targets.
24881
+ */
24882
+ function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples) {
24883
+ const counts = /* @__PURE__ */ new Map();
24884
+ for (const s of gallery) {
24885
+ if (s.modelId !== probeModelId) continue;
24886
+ if (s.embedding.length !== probeDim) continue;
24887
+ counts.set(s.identityId, (counts.get(s.identityId) ?? 0) + 1);
24888
+ }
24889
+ const eligible = /* @__PURE__ */ new Set();
24890
+ for (const [id, count] of counts) if (count >= minIdentitySamples) eligible.add(id);
24891
+ return eligible;
24892
+ }
24893
+ /**
24894
+ * Match a probe embedding against the gallery. Only samples with the same
24895
+ * `modelId` AND the same dimension are compared (model-version safety). Returns
24896
+ * the best identity when its score ≥ threshold and it beats the best OTHER
24897
+ * identity by ≥ margin; otherwise null.
24898
+ */
24899
+ function matchEmbedding(probe, gallery, opts) {
24900
+ const probeVec = new Float32Array(probe.embedding);
24901
+ const eligible = eligibleIdentities(gallery, probe.modelId, probe.embedding.length, opts.minIdentitySamples ?? 1);
24902
+ const bestByIdentity = /* @__PURE__ */ new Map();
24903
+ for (const s of gallery) {
24904
+ if (s.modelId !== probe.modelId) continue;
24905
+ if (s.embedding.length !== probe.embedding.length) continue;
24906
+ if (!eligible.has(s.identityId)) continue;
24907
+ const score = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
24908
+ const prev = bestByIdentity.get(s.identityId);
24909
+ if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
24910
+ }
24911
+ if (bestByIdentity.size === 0) return null;
24912
+ let bestId = null;
24913
+ let bestScore = -Infinity;
24914
+ let secondScore = -Infinity;
24915
+ for (const [id, score] of bestByIdentity) if (score > bestScore) {
24916
+ secondScore = bestScore;
24917
+ bestScore = score;
24918
+ bestId = id;
24919
+ } else if (score > secondScore) secondScore = score;
24920
+ if (bestId === null || bestScore < opts.threshold) return null;
24921
+ if (secondScore > -Infinity && bestScore - secondScore < opts.margin) return null;
24922
+ return {
24923
+ identityId: bestId,
24924
+ score: bestScore
24925
+ };
24926
+ }
24927
+ /**
24928
+ * Assign at most one identity per track AND at most one track per identity for
24929
+ * a single frame. Greedy by score: compute every candidate's full ranked match
24930
+ * list, then repeatedly take the globally-highest (track, identity) pair whose
24931
+ * track and identity are both still free, until none qualify. A losing track
24932
+ * falls back to its next-best free identity ≥ threshold. Ties resolve by input order.
24933
+ */
24934
+ /**
24935
+ * Enrollment bound: the maximum cosine an embedding may score against a sample
24936
+ * of a DIFFERENT identity and still be enrolled. Genuine ArcFace cross-person
24937
+ * cosines sit under ~0.4; the low-information attractor (IR, blur, backs of
24938
+ * heads) sits at 0.95+. Measured 2026-08-18: 22 of 50 live samples were
24939
+ * enrolled from that attractor, with inter-identity cosines up to 0.988 — each
24940
+ * one a magnet that "recognises" every degraded probe as its owner.
24941
+ */
24942
+ var ENROLLMENT_MAX_FOREIGN_COSINE = .9;
24943
+ /**
24944
+ * Gate an embedding BEFORE it becomes a gallery sample: condemned when its
24945
+ * best cosine against any sample of ANOTHER identity (same model + dimension)
24946
+ * reaches {@link ENROLLMENT_MAX_FOREIGN_COSINE}. Null = safe to enroll.
24947
+ * Closeness to the TARGET identity's own samples is expected and never
24948
+ * condemns.
24949
+ */
24950
+ function enrollmentPromiscuity(embedding, modelId, targetIdentityId, gallery, bound = ENROLLMENT_MAX_FOREIGN_COSINE) {
24951
+ const probeVec = new Float32Array(embedding);
24952
+ let worst = null;
24953
+ for (const s of gallery) {
24954
+ if (s.identityId === targetIdentityId) continue;
24955
+ if (s.modelId !== modelId) continue;
24956
+ if (s.embedding.length !== embedding.length) continue;
24957
+ const cosine = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
24958
+ if (cosine >= bound && (worst === null || cosine > worst.cosine)) worst = {
24959
+ identityId: s.identityId,
24960
+ cosine
24961
+ };
24962
+ }
24963
+ return worst;
24964
+ }
24965
+ function assignUniquePerFrame(candidates, gallery, opts) {
24966
+ const pairs = [];
24967
+ candidates.forEach((c, trackIdx) => {
24968
+ const probeVec = new Float32Array(c.embedding);
24969
+ const eligible = eligibleIdentities(gallery, c.modelId, c.embedding.length, opts.minIdentitySamples ?? 1);
24970
+ const bestByIdentity = /* @__PURE__ */ new Map();
24971
+ for (const s of gallery) {
24972
+ if (s.modelId !== c.modelId) continue;
24973
+ if (s.embedding.length !== c.embedding.length) continue;
24974
+ if (!eligible.has(s.identityId)) continue;
24975
+ const score = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
24976
+ const prev = bestByIdentity.get(s.identityId);
24977
+ if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
24978
+ }
24979
+ let bestId = null;
24980
+ let bestScore = -1;
24981
+ let secondScore = -1;
24982
+ for (const [identityId, score] of bestByIdentity) if (score > bestScore) {
24983
+ if (bestId !== null) secondScore = bestScore;
24984
+ bestId = identityId;
24985
+ bestScore = score;
24986
+ } else if (score > secondScore) secondScore = score;
24987
+ const ambiguous = secondScore >= 0 && bestScore - secondScore < opts.margin;
24988
+ if (bestId !== null && !ambiguous) {
24989
+ for (const [identityId, score] of bestByIdentity) if (score >= opts.threshold) pairs.push({
24990
+ trackIdx,
24991
+ identityId,
24992
+ score
24993
+ });
24994
+ }
24995
+ });
24996
+ pairs.sort((a, b) => b.score - a.score);
24997
+ const takenTracks = /* @__PURE__ */ new Set();
24998
+ const takenIdentities = /* @__PURE__ */ new Set();
24999
+ const result = /* @__PURE__ */ new Map();
25000
+ for (const p of pairs) {
25001
+ if (takenTracks.has(p.trackIdx) || takenIdentities.has(p.identityId)) continue;
25002
+ takenTracks.add(p.trackIdx);
25003
+ takenIdentities.add(p.identityId);
25004
+ result.set(candidates[p.trackIdx].trackId, {
25005
+ identityId: p.identityId,
25006
+ score: p.score
25007
+ });
25008
+ }
25009
+ return result;
25010
+ }
25011
+ var EMPTY_AGGREGATE = Object.freeze({
25012
+ assignedIdentityId: null,
25013
+ confirmations: Object.freeze({})
25014
+ });
25015
+ /**
25016
+ * Fold one frame's match into a track's aggregate. A null match leaves state
25017
+ * untouched. An identity is assigned once its cumulative confirmation count
25018
+ * reaches `confirmFrames`; once assigned, a different identity must independently
25019
+ * reach `confirmFrames` to switch. `changed` is true only on a (re)assignment to
25020
+ * a NEW identity, so callers fire the label update exactly once per transition.
25021
+ *
25022
+ * On each (re)assignment the confirmations record is reset to contain only the
25023
+ * newly-assigned identity's count, so any challenger must independently re-reach
25024
+ * `confirmFrames` from scratch after any assignment.
25025
+ */
25026
+ function updateTrackAggregate(prev, match, opts) {
25027
+ if (match === null) return {
25028
+ state: prev,
25029
+ changed: false
25030
+ };
25031
+ const count = (prev.confirmations[match.identityId] ?? 0) + 1;
25032
+ const confirmations = {
25033
+ ...prev.confirmations,
25034
+ [match.identityId]: count
25035
+ };
25036
+ if (count < opts.confirmFrames) return {
25037
+ state: {
25038
+ ...prev,
25039
+ confirmations
25040
+ },
25041
+ changed: false
25042
+ };
25043
+ if (prev.assignedIdentityId === match.identityId) return {
25044
+ state: {
25045
+ ...prev,
25046
+ confirmations
25047
+ },
25048
+ changed: false
25049
+ };
25050
+ return {
25051
+ state: {
25052
+ assignedIdentityId: match.identityId,
25053
+ confirmations: { [match.identityId]: count }
25054
+ },
25055
+ changed: true
25056
+ };
25057
+ }
25058
+ //#endregion
24859
25059
  //#region src/pipeline-analytics/face-gallery-provider.ts
24860
25060
  /** Attribution of a face identity NAMED BY THE OPERATOR in the gallery. */
24861
25061
  var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
@@ -25102,6 +25302,19 @@ var FaceGalleryProvider = class {
25102
25302
  }
25103
25303
  const currentFace = await this.faceStore.get(faceId);
25104
25304
  if (!currentFace) throw new Error(`FaceGalleryProvider.assignFace: face disappeared after pre-cleanup: ${faceId}`);
25305
+ const verdict = enrollmentPromiscuity(currentFace.embedding, MODEL_ID, identityId, await this.identityStore.loadGallery());
25306
+ if (verdict !== null) {
25307
+ this.logger.warn("assignFace refused: embedding too close to a FOREIGN identity sample", {
25308
+ tags: { deviceId: currentFace.deviceId },
25309
+ meta: {
25310
+ faceId,
25311
+ identityId,
25312
+ foreignIdentityId: verdict.identityId,
25313
+ cosine: verdict.cosine
25314
+ }
25315
+ });
25316
+ throw new Error(`FaceGalleryProvider.assignFace: embedding is ambiguous — cosine ${verdict.cosine.toFixed(3)} with a sample of another identity (${verdict.identityId}); pick a clearer crop`);
25317
+ }
25105
25318
  let newKey;
25106
25319
  if (currentFace.mediaKey) {
25107
25320
  newKey = await this.mediaStore.reown({
@@ -33552,146 +33765,6 @@ async function resolveDefaultEventMedia(deps) {
33552
33765
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
33553
33766
  }
33554
33767
  //#endregion
33555
- //#region src/pipeline-analytics/pipeline/face-matcher.ts
33556
- /**
33557
- * Count enrolled samples per identity for probes of a matching model+dimension.
33558
- * Only identities meeting `minIdentitySamples` are eligible auto-match targets.
33559
- */
33560
- function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples) {
33561
- const counts = /* @__PURE__ */ new Map();
33562
- for (const s of gallery) {
33563
- if (s.modelId !== probeModelId) continue;
33564
- if (s.embedding.length !== probeDim) continue;
33565
- counts.set(s.identityId, (counts.get(s.identityId) ?? 0) + 1);
33566
- }
33567
- const eligible = /* @__PURE__ */ new Set();
33568
- for (const [id, count] of counts) if (count >= minIdentitySamples) eligible.add(id);
33569
- return eligible;
33570
- }
33571
- /**
33572
- * Match a probe embedding against the gallery. Only samples with the same
33573
- * `modelId` AND the same dimension are compared (model-version safety). Returns
33574
- * the best identity when its score ≥ threshold and it beats the best OTHER
33575
- * identity by ≥ margin; otherwise null.
33576
- */
33577
- function matchEmbedding(probe, gallery, opts) {
33578
- const probeVec = new Float32Array(probe.embedding);
33579
- const eligible = eligibleIdentities(gallery, probe.modelId, probe.embedding.length, opts.minIdentitySamples ?? 1);
33580
- const bestByIdentity = /* @__PURE__ */ new Map();
33581
- for (const s of gallery) {
33582
- if (s.modelId !== probe.modelId) continue;
33583
- if (s.embedding.length !== probe.embedding.length) continue;
33584
- if (!eligible.has(s.identityId)) continue;
33585
- const score = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
33586
- const prev = bestByIdentity.get(s.identityId);
33587
- if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
33588
- }
33589
- if (bestByIdentity.size === 0) return null;
33590
- let bestId = null;
33591
- let bestScore = -Infinity;
33592
- let secondScore = -Infinity;
33593
- for (const [id, score] of bestByIdentity) if (score > bestScore) {
33594
- secondScore = bestScore;
33595
- bestScore = score;
33596
- bestId = id;
33597
- } else if (score > secondScore) secondScore = score;
33598
- if (bestId === null || bestScore < opts.threshold) return null;
33599
- if (secondScore > -Infinity && bestScore - secondScore < opts.margin) return null;
33600
- return {
33601
- identityId: bestId,
33602
- score: bestScore
33603
- };
33604
- }
33605
- /**
33606
- * Assign at most one identity per track AND at most one track per identity for
33607
- * a single frame. Greedy by score: compute every candidate's full ranked match
33608
- * list, then repeatedly take the globally-highest (track, identity) pair whose
33609
- * track and identity are both still free, until none qualify. A losing track
33610
- * falls back to its next-best free identity ≥ threshold. Ties resolve by input order.
33611
- */
33612
- function assignUniquePerFrame(candidates, gallery, opts) {
33613
- const pairs = [];
33614
- candidates.forEach((c, trackIdx) => {
33615
- const probeVec = new Float32Array(c.embedding);
33616
- const eligible = eligibleIdentities(gallery, c.modelId, c.embedding.length, opts.minIdentitySamples ?? 1);
33617
- const bestByIdentity = /* @__PURE__ */ new Map();
33618
- for (const s of gallery) {
33619
- if (s.modelId !== c.modelId) continue;
33620
- if (s.embedding.length !== c.embedding.length) continue;
33621
- if (!eligible.has(s.identityId)) continue;
33622
- const score = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
33623
- const prev = bestByIdentity.get(s.identityId);
33624
- if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
33625
- }
33626
- for (const [identityId, score] of bestByIdentity) if (score >= opts.threshold) pairs.push({
33627
- trackIdx,
33628
- identityId,
33629
- score
33630
- });
33631
- });
33632
- pairs.sort((a, b) => b.score - a.score);
33633
- const takenTracks = /* @__PURE__ */ new Set();
33634
- const takenIdentities = /* @__PURE__ */ new Set();
33635
- const result = /* @__PURE__ */ new Map();
33636
- for (const p of pairs) {
33637
- if (takenTracks.has(p.trackIdx) || takenIdentities.has(p.identityId)) continue;
33638
- takenTracks.add(p.trackIdx);
33639
- takenIdentities.add(p.identityId);
33640
- result.set(candidates[p.trackIdx].trackId, {
33641
- identityId: p.identityId,
33642
- score: p.score
33643
- });
33644
- }
33645
- return result;
33646
- }
33647
- var EMPTY_AGGREGATE = Object.freeze({
33648
- assignedIdentityId: null,
33649
- confirmations: Object.freeze({})
33650
- });
33651
- /**
33652
- * Fold one frame's match into a track's aggregate. A null match leaves state
33653
- * untouched. An identity is assigned once its cumulative confirmation count
33654
- * reaches `confirmFrames`; once assigned, a different identity must independently
33655
- * reach `confirmFrames` to switch. `changed` is true only on a (re)assignment to
33656
- * a NEW identity, so callers fire the label update exactly once per transition.
33657
- *
33658
- * On each (re)assignment the confirmations record is reset to contain only the
33659
- * newly-assigned identity's count, so any challenger must independently re-reach
33660
- * `confirmFrames` from scratch after any assignment.
33661
- */
33662
- function updateTrackAggregate(prev, match, opts) {
33663
- if (match === null) return {
33664
- state: prev,
33665
- changed: false
33666
- };
33667
- const count = (prev.confirmations[match.identityId] ?? 0) + 1;
33668
- const confirmations = {
33669
- ...prev.confirmations,
33670
- [match.identityId]: count
33671
- };
33672
- if (count < opts.confirmFrames) return {
33673
- state: {
33674
- ...prev,
33675
- confirmations
33676
- },
33677
- changed: false
33678
- };
33679
- if (prev.assignedIdentityId === match.identityId) return {
33680
- state: {
33681
- ...prev,
33682
- confirmations
33683
- },
33684
- changed: false
33685
- };
33686
- return {
33687
- state: {
33688
- assignedIdentityId: match.identityId,
33689
- confirmations: { [match.identityId]: count }
33690
- },
33691
- changed: true
33692
- };
33693
- }
33694
- //#endregion
33695
33768
  //#region src/pipeline-analytics/face-recognizer.ts
33696
33769
  /** Attribution of an identity resolved by the in-process ArcFace matcher. */
33697
33770
  var FACE_RECOGNIZER_STEP_ID = "face-recognizer";
@@ -52396,6 +52469,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
52396
52469
  },
52397
52470
  renderEventMedia: (req) => this.renderEventMedia(req),
52398
52471
  publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
52472
+ viewerUrlBase: async () => pickArtifactBaseUrl(await this.ncEndpoints()),
52399
52473
  indexArtifact: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? {
52400
52474
  url: null,
52401
52475
  artifactId: null
@@ -6398,6 +6398,16 @@ var NcDeviceStateCache = class {
6398
6398
  }
6399
6399
  };
6400
6400
  //#endregion
6401
+ //#region src/notification-center/default-tap-url.ts
6402
+ /** The viewer's hosted base path under the hub. */
6403
+ var VIEWER_BASE_PATH = "/viewer/camstack";
6404
+ function defaultTapUrl(input) {
6405
+ if (input.systemEvent === true) return null;
6406
+ if (input.baseUrl === null || input.baseUrl.length === 0) return null;
6407
+ if (!Number.isInteger(input.deviceId) || input.deviceId <= 0) return null;
6408
+ return `${input.baseUrl.replace(/\/+$/, "")}${VIEWER_BASE_PATH}/camera/${String(input.deviceId)}?at=${String(input.timestampMs)}`;
6409
+ }
6410
+ //#endregion
6401
6411
  //#region src/shared/frame/box-drawer.ts
6402
6412
  var DEFAULT_COLOR = DEFAULT_EVENT_COLOR;
6403
6413
  var DEFAULT_QUALITY = 80;
@@ -9255,12 +9265,19 @@ var NcDispatcher = class {
9255
9265
  }) : rendered;
9256
9266
  const attachments = [...await this.withArtifactUrls(entry, await this.resolveAttachments(entry)), ...frozenArtifactAttachments(entry)];
9257
9267
  const params = pickParams(entry.payload.params);
9268
+ const tapUrl = params.clickUrl === void 0 && this.deps.viewerUrlBase !== void 0 ? defaultTapUrl({
9269
+ deviceId: systemEvent !== void 0 ? 0 : subject.deviceId,
9270
+ timestampMs: subject.timestamp,
9271
+ baseUrl: await this.deps.viewerUrlBase().catch(() => null),
9272
+ systemEvent: systemEvent !== void 0
9273
+ }) : null;
9258
9274
  const actions = await this.mintActions(entry, target);
9259
9275
  return {
9260
9276
  body,
9261
9277
  title,
9262
9278
  priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
9263
9279
  ...params,
9280
+ ...tapUrl !== null ? { clickUrl: tapUrl } : {},
9264
9281
  tag: entry.payload.group?.key ?? entry.ruleId,
9265
9282
  ...systemEvent !== void 0 ? systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {} : { deviceId: subject.deviceId },
9266
9283
  ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
@@ -19126,7 +19143,8 @@ var NotificationCenter = class NotificationCenter {
19126
19143
  confirmGate: (input) => confirmGate.judge(input),
19127
19144
  confirmStats: () => confirmGate.stats()
19128
19145
  } : {},
19129
- releaseCooldown: (input) => this.releaseCooldown(input)
19146
+ releaseCooldown: (input) => this.releaseCooldown(input),
19147
+ ...deps.dispatcher.viewerUrlBase !== void 0 ? { viewerUrlBase: deps.dispatcher.viewerUrlBase } : {}
19130
19148
  });
19131
19149
  this.outbox = new NcOutbox({
19132
19150
  store: deps.store,
@@ -24834,6 +24852,188 @@ function clusterByEmbedding(items, threshold = .5) {
24834
24852
  })).toSorted((a, b) => b.size - a.size || b.cohesion - a.cohesion);
24835
24853
  }
24836
24854
  //#endregion
24855
+ //#region src/pipeline-analytics/pipeline/face-matcher.ts
24856
+ /**
24857
+ * Count enrolled samples per identity for probes of a matching model+dimension.
24858
+ * Only identities meeting `minIdentitySamples` are eligible auto-match targets.
24859
+ */
24860
+ function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples) {
24861
+ const counts = /* @__PURE__ */ new Map();
24862
+ for (const s of gallery) {
24863
+ if (s.modelId !== probeModelId) continue;
24864
+ if (s.embedding.length !== probeDim) continue;
24865
+ counts.set(s.identityId, (counts.get(s.identityId) ?? 0) + 1);
24866
+ }
24867
+ const eligible = /* @__PURE__ */ new Set();
24868
+ for (const [id, count] of counts) if (count >= minIdentitySamples) eligible.add(id);
24869
+ return eligible;
24870
+ }
24871
+ /**
24872
+ * Match a probe embedding against the gallery. Only samples with the same
24873
+ * `modelId` AND the same dimension are compared (model-version safety). Returns
24874
+ * the best identity when its score ≥ threshold and it beats the best OTHER
24875
+ * identity by ≥ margin; otherwise null.
24876
+ */
24877
+ function matchEmbedding(probe, gallery, opts) {
24878
+ const probeVec = new Float32Array(probe.embedding);
24879
+ const eligible = eligibleIdentities(gallery, probe.modelId, probe.embedding.length, opts.minIdentitySamples ?? 1);
24880
+ const bestByIdentity = /* @__PURE__ */ new Map();
24881
+ for (const s of gallery) {
24882
+ if (s.modelId !== probe.modelId) continue;
24883
+ if (s.embedding.length !== probe.embedding.length) continue;
24884
+ if (!eligible.has(s.identityId)) continue;
24885
+ const score = cosineSimilarity$1(probeVec, new Float32Array(s.embedding));
24886
+ const prev = bestByIdentity.get(s.identityId);
24887
+ if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
24888
+ }
24889
+ if (bestByIdentity.size === 0) return null;
24890
+ let bestId = null;
24891
+ let bestScore = -Infinity;
24892
+ let secondScore = -Infinity;
24893
+ for (const [id, score] of bestByIdentity) if (score > bestScore) {
24894
+ secondScore = bestScore;
24895
+ bestScore = score;
24896
+ bestId = id;
24897
+ } else if (score > secondScore) secondScore = score;
24898
+ if (bestId === null || bestScore < opts.threshold) return null;
24899
+ if (secondScore > -Infinity && bestScore - secondScore < opts.margin) return null;
24900
+ return {
24901
+ identityId: bestId,
24902
+ score: bestScore
24903
+ };
24904
+ }
24905
+ /**
24906
+ * Assign at most one identity per track AND at most one track per identity for
24907
+ * a single frame. Greedy by score: compute every candidate's full ranked match
24908
+ * list, then repeatedly take the globally-highest (track, identity) pair whose
24909
+ * track and identity are both still free, until none qualify. A losing track
24910
+ * falls back to its next-best free identity ≥ threshold. Ties resolve by input order.
24911
+ */
24912
+ /**
24913
+ * Enrollment bound: the maximum cosine an embedding may score against a sample
24914
+ * of a DIFFERENT identity and still be enrolled. Genuine ArcFace cross-person
24915
+ * cosines sit under ~0.4; the low-information attractor (IR, blur, backs of
24916
+ * heads) sits at 0.95+. Measured 2026-08-18: 22 of 50 live samples were
24917
+ * enrolled from that attractor, with inter-identity cosines up to 0.988 — each
24918
+ * one a magnet that "recognises" every degraded probe as its owner.
24919
+ */
24920
+ var ENROLLMENT_MAX_FOREIGN_COSINE = .9;
24921
+ /**
24922
+ * Gate an embedding BEFORE it becomes a gallery sample: condemned when its
24923
+ * best cosine against any sample of ANOTHER identity (same model + dimension)
24924
+ * reaches {@link ENROLLMENT_MAX_FOREIGN_COSINE}. Null = safe to enroll.
24925
+ * Closeness to the TARGET identity's own samples is expected and never
24926
+ * condemns.
24927
+ */
24928
+ function enrollmentPromiscuity(embedding, modelId, targetIdentityId, gallery, bound = ENROLLMENT_MAX_FOREIGN_COSINE) {
24929
+ const probeVec = new Float32Array(embedding);
24930
+ let worst = null;
24931
+ for (const s of gallery) {
24932
+ if (s.identityId === targetIdentityId) continue;
24933
+ if (s.modelId !== modelId) continue;
24934
+ if (s.embedding.length !== embedding.length) continue;
24935
+ const cosine = cosineSimilarity$1(probeVec, new Float32Array(s.embedding));
24936
+ if (cosine >= bound && (worst === null || cosine > worst.cosine)) worst = {
24937
+ identityId: s.identityId,
24938
+ cosine
24939
+ };
24940
+ }
24941
+ return worst;
24942
+ }
24943
+ function assignUniquePerFrame(candidates, gallery, opts) {
24944
+ const pairs = [];
24945
+ candidates.forEach((c, trackIdx) => {
24946
+ const probeVec = new Float32Array(c.embedding);
24947
+ const eligible = eligibleIdentities(gallery, c.modelId, c.embedding.length, opts.minIdentitySamples ?? 1);
24948
+ const bestByIdentity = /* @__PURE__ */ new Map();
24949
+ for (const s of gallery) {
24950
+ if (s.modelId !== c.modelId) continue;
24951
+ if (s.embedding.length !== c.embedding.length) continue;
24952
+ if (!eligible.has(s.identityId)) continue;
24953
+ const score = cosineSimilarity$1(probeVec, new Float32Array(s.embedding));
24954
+ const prev = bestByIdentity.get(s.identityId);
24955
+ if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
24956
+ }
24957
+ let bestId = null;
24958
+ let bestScore = -1;
24959
+ let secondScore = -1;
24960
+ for (const [identityId, score] of bestByIdentity) if (score > bestScore) {
24961
+ if (bestId !== null) secondScore = bestScore;
24962
+ bestId = identityId;
24963
+ bestScore = score;
24964
+ } else if (score > secondScore) secondScore = score;
24965
+ const ambiguous = secondScore >= 0 && bestScore - secondScore < opts.margin;
24966
+ if (bestId !== null && !ambiguous) {
24967
+ for (const [identityId, score] of bestByIdentity) if (score >= opts.threshold) pairs.push({
24968
+ trackIdx,
24969
+ identityId,
24970
+ score
24971
+ });
24972
+ }
24973
+ });
24974
+ pairs.sort((a, b) => b.score - a.score);
24975
+ const takenTracks = /* @__PURE__ */ new Set();
24976
+ const takenIdentities = /* @__PURE__ */ new Set();
24977
+ const result = /* @__PURE__ */ new Map();
24978
+ for (const p of pairs) {
24979
+ if (takenTracks.has(p.trackIdx) || takenIdentities.has(p.identityId)) continue;
24980
+ takenTracks.add(p.trackIdx);
24981
+ takenIdentities.add(p.identityId);
24982
+ result.set(candidates[p.trackIdx].trackId, {
24983
+ identityId: p.identityId,
24984
+ score: p.score
24985
+ });
24986
+ }
24987
+ return result;
24988
+ }
24989
+ var EMPTY_AGGREGATE = Object.freeze({
24990
+ assignedIdentityId: null,
24991
+ confirmations: Object.freeze({})
24992
+ });
24993
+ /**
24994
+ * Fold one frame's match into a track's aggregate. A null match leaves state
24995
+ * untouched. An identity is assigned once its cumulative confirmation count
24996
+ * reaches `confirmFrames`; once assigned, a different identity must independently
24997
+ * reach `confirmFrames` to switch. `changed` is true only on a (re)assignment to
24998
+ * a NEW identity, so callers fire the label update exactly once per transition.
24999
+ *
25000
+ * On each (re)assignment the confirmations record is reset to contain only the
25001
+ * newly-assigned identity's count, so any challenger must independently re-reach
25002
+ * `confirmFrames` from scratch after any assignment.
25003
+ */
25004
+ function updateTrackAggregate(prev, match, opts) {
25005
+ if (match === null) return {
25006
+ state: prev,
25007
+ changed: false
25008
+ };
25009
+ const count = (prev.confirmations[match.identityId] ?? 0) + 1;
25010
+ const confirmations = {
25011
+ ...prev.confirmations,
25012
+ [match.identityId]: count
25013
+ };
25014
+ if (count < opts.confirmFrames) return {
25015
+ state: {
25016
+ ...prev,
25017
+ confirmations
25018
+ },
25019
+ changed: false
25020
+ };
25021
+ if (prev.assignedIdentityId === match.identityId) return {
25022
+ state: {
25023
+ ...prev,
25024
+ confirmations
25025
+ },
25026
+ changed: false
25027
+ };
25028
+ return {
25029
+ state: {
25030
+ assignedIdentityId: match.identityId,
25031
+ confirmations: { [match.identityId]: count }
25032
+ },
25033
+ changed: true
25034
+ };
25035
+ }
25036
+ //#endregion
24837
25037
  //#region src/pipeline-analytics/face-gallery-provider.ts
24838
25038
  /** Attribution of a face identity NAMED BY THE OPERATOR in the gallery. */
24839
25039
  var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
@@ -25080,6 +25280,19 @@ var FaceGalleryProvider = class {
25080
25280
  }
25081
25281
  const currentFace = await this.faceStore.get(faceId);
25082
25282
  if (!currentFace) throw new Error(`FaceGalleryProvider.assignFace: face disappeared after pre-cleanup: ${faceId}`);
25283
+ const verdict = enrollmentPromiscuity(currentFace.embedding, MODEL_ID, identityId, await this.identityStore.loadGallery());
25284
+ if (verdict !== null) {
25285
+ this.logger.warn("assignFace refused: embedding too close to a FOREIGN identity sample", {
25286
+ tags: { deviceId: currentFace.deviceId },
25287
+ meta: {
25288
+ faceId,
25289
+ identityId,
25290
+ foreignIdentityId: verdict.identityId,
25291
+ cosine: verdict.cosine
25292
+ }
25293
+ });
25294
+ throw new Error(`FaceGalleryProvider.assignFace: embedding is ambiguous — cosine ${verdict.cosine.toFixed(3)} with a sample of another identity (${verdict.identityId}); pick a clearer crop`);
25295
+ }
25083
25296
  let newKey;
25084
25297
  if (currentFace.mediaKey) {
25085
25298
  newKey = await this.mediaStore.reown({
@@ -33492,146 +33705,6 @@ async function resolveDefaultEventMedia(deps) {
33492
33705
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
33493
33706
  }
33494
33707
  //#endregion
33495
- //#region src/pipeline-analytics/pipeline/face-matcher.ts
33496
- /**
33497
- * Count enrolled samples per identity for probes of a matching model+dimension.
33498
- * Only identities meeting `minIdentitySamples` are eligible auto-match targets.
33499
- */
33500
- function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples) {
33501
- const counts = /* @__PURE__ */ new Map();
33502
- for (const s of gallery) {
33503
- if (s.modelId !== probeModelId) continue;
33504
- if (s.embedding.length !== probeDim) continue;
33505
- counts.set(s.identityId, (counts.get(s.identityId) ?? 0) + 1);
33506
- }
33507
- const eligible = /* @__PURE__ */ new Set();
33508
- for (const [id, count] of counts) if (count >= minIdentitySamples) eligible.add(id);
33509
- return eligible;
33510
- }
33511
- /**
33512
- * Match a probe embedding against the gallery. Only samples with the same
33513
- * `modelId` AND the same dimension are compared (model-version safety). Returns
33514
- * the best identity when its score ≥ threshold and it beats the best OTHER
33515
- * identity by ≥ margin; otherwise null.
33516
- */
33517
- function matchEmbedding(probe, gallery, opts) {
33518
- const probeVec = new Float32Array(probe.embedding);
33519
- const eligible = eligibleIdentities(gallery, probe.modelId, probe.embedding.length, opts.minIdentitySamples ?? 1);
33520
- const bestByIdentity = /* @__PURE__ */ new Map();
33521
- for (const s of gallery) {
33522
- if (s.modelId !== probe.modelId) continue;
33523
- if (s.embedding.length !== probe.embedding.length) continue;
33524
- if (!eligible.has(s.identityId)) continue;
33525
- const score = cosineSimilarity$1(probeVec, new Float32Array(s.embedding));
33526
- const prev = bestByIdentity.get(s.identityId);
33527
- if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
33528
- }
33529
- if (bestByIdentity.size === 0) return null;
33530
- let bestId = null;
33531
- let bestScore = -Infinity;
33532
- let secondScore = -Infinity;
33533
- for (const [id, score] of bestByIdentity) if (score > bestScore) {
33534
- secondScore = bestScore;
33535
- bestScore = score;
33536
- bestId = id;
33537
- } else if (score > secondScore) secondScore = score;
33538
- if (bestId === null || bestScore < opts.threshold) return null;
33539
- if (secondScore > -Infinity && bestScore - secondScore < opts.margin) return null;
33540
- return {
33541
- identityId: bestId,
33542
- score: bestScore
33543
- };
33544
- }
33545
- /**
33546
- * Assign at most one identity per track AND at most one track per identity for
33547
- * a single frame. Greedy by score: compute every candidate's full ranked match
33548
- * list, then repeatedly take the globally-highest (track, identity) pair whose
33549
- * track and identity are both still free, until none qualify. A losing track
33550
- * falls back to its next-best free identity ≥ threshold. Ties resolve by input order.
33551
- */
33552
- function assignUniquePerFrame(candidates, gallery, opts) {
33553
- const pairs = [];
33554
- candidates.forEach((c, trackIdx) => {
33555
- const probeVec = new Float32Array(c.embedding);
33556
- const eligible = eligibleIdentities(gallery, c.modelId, c.embedding.length, opts.minIdentitySamples ?? 1);
33557
- const bestByIdentity = /* @__PURE__ */ new Map();
33558
- for (const s of gallery) {
33559
- if (s.modelId !== c.modelId) continue;
33560
- if (s.embedding.length !== c.embedding.length) continue;
33561
- if (!eligible.has(s.identityId)) continue;
33562
- const score = cosineSimilarity$1(probeVec, new Float32Array(s.embedding));
33563
- const prev = bestByIdentity.get(s.identityId);
33564
- if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
33565
- }
33566
- for (const [identityId, score] of bestByIdentity) if (score >= opts.threshold) pairs.push({
33567
- trackIdx,
33568
- identityId,
33569
- score
33570
- });
33571
- });
33572
- pairs.sort((a, b) => b.score - a.score);
33573
- const takenTracks = /* @__PURE__ */ new Set();
33574
- const takenIdentities = /* @__PURE__ */ new Set();
33575
- const result = /* @__PURE__ */ new Map();
33576
- for (const p of pairs) {
33577
- if (takenTracks.has(p.trackIdx) || takenIdentities.has(p.identityId)) continue;
33578
- takenTracks.add(p.trackIdx);
33579
- takenIdentities.add(p.identityId);
33580
- result.set(candidates[p.trackIdx].trackId, {
33581
- identityId: p.identityId,
33582
- score: p.score
33583
- });
33584
- }
33585
- return result;
33586
- }
33587
- var EMPTY_AGGREGATE = Object.freeze({
33588
- assignedIdentityId: null,
33589
- confirmations: Object.freeze({})
33590
- });
33591
- /**
33592
- * Fold one frame's match into a track's aggregate. A null match leaves state
33593
- * untouched. An identity is assigned once its cumulative confirmation count
33594
- * reaches `confirmFrames`; once assigned, a different identity must independently
33595
- * reach `confirmFrames` to switch. `changed` is true only on a (re)assignment to
33596
- * a NEW identity, so callers fire the label update exactly once per transition.
33597
- *
33598
- * On each (re)assignment the confirmations record is reset to contain only the
33599
- * newly-assigned identity's count, so any challenger must independently re-reach
33600
- * `confirmFrames` from scratch after any assignment.
33601
- */
33602
- function updateTrackAggregate(prev, match, opts) {
33603
- if (match === null) return {
33604
- state: prev,
33605
- changed: false
33606
- };
33607
- const count = (prev.confirmations[match.identityId] ?? 0) + 1;
33608
- const confirmations = {
33609
- ...prev.confirmations,
33610
- [match.identityId]: count
33611
- };
33612
- if (count < opts.confirmFrames) return {
33613
- state: {
33614
- ...prev,
33615
- confirmations
33616
- },
33617
- changed: false
33618
- };
33619
- if (prev.assignedIdentityId === match.identityId) return {
33620
- state: {
33621
- ...prev,
33622
- confirmations
33623
- },
33624
- changed: false
33625
- };
33626
- return {
33627
- state: {
33628
- assignedIdentityId: match.identityId,
33629
- confirmations: { [match.identityId]: count }
33630
- },
33631
- changed: true
33632
- };
33633
- }
33634
- //#endregion
33635
33708
  //#region src/pipeline-analytics/face-recognizer.ts
33636
33709
  /** Attribution of an identity resolved by the in-process ArcFace matcher. */
33637
33710
  var FACE_RECOGNIZER_STEP_ID = "face-recognizer";
@@ -52336,6 +52409,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
52336
52409
  },
52337
52410
  renderEventMedia: (req) => this.renderEventMedia(req),
52338
52411
  publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
52412
+ viewerUrlBase: async () => pickArtifactBaseUrl(await this.ncEndpoints()),
52339
52413
  indexArtifact: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? {
52340
52414
  url: null,
52341
52415
  artifactId: null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.94",
3
+ "version": "1.2.95",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",