@camstack/addon-post-analysis 1.2.93 → 1.2.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21525,6 +21525,14 @@ var ClipSchema = object({
21525
21525
  endMs: number()
21526
21526
  }),
21527
21527
  /**
21528
+ * Distinct object classes attached to this visit (`person`, `car`, …),
21529
+ * dominant first, capped at {@link MAX_CLIP_LABELS}. The ribbon renders these
21530
+ * instead of the bare kind — "Object" tells the operator nothing a colour bar
21531
+ * did not. Absent (never empty) when the visit attached no classified object
21532
+ * event, so a motion/audio-only visit keeps its kind label.
21533
+ */
21534
+ labels: array(string()).max(3).optional(),
21535
+ /**
21528
21536
  * Lazy thumbnail URL, never inlined.
21529
21537
  *
21530
21538
  * Recording-derived clips (events-mode keep-window, and the prepared
@@ -21494,6 +21494,14 @@ var ClipSchema = object({
21494
21494
  endMs: number()
21495
21495
  }),
21496
21496
  /**
21497
+ * Distinct object classes attached to this visit (`person`, `car`, …),
21498
+ * dominant first, capped at {@link MAX_CLIP_LABELS}. The ribbon renders these
21499
+ * instead of the bare kind — "Object" tells the operator nothing a colour bar
21500
+ * did not. Absent (never empty) when the visit attached no classified object
21501
+ * event, so a motion/audio-only visit keeps its kind label.
21502
+ */
21503
+ labels: array(string()).max(3).optional(),
21504
+ /**
21497
21505
  * Lazy thumbnail URL, never inlined.
21498
21506
  *
21499
21507
  * Recording-derived clips (events-mode keep-window, and the prepared
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DYoWeRRt.js");
5
+ const require_dist = require("../dist-BdojoP7i.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_fs$1 = require_dist.__toESM(node_fs, 1);
8
8
  node_fs = require_dist.__toESM(node_fs);
@@ -1,4 +1,4 @@
1
- import { O as PoolMemoryWatchdog, Q as embeddingEncoderCapability, dt as resolvePoolMemoryPolicy, ot as parseProcStatus, tt as hfModelUrl, yt as BaseAddon } from "../dist-3QRyOemL.mjs";
1
+ import { O as PoolMemoryWatchdog, Q as embeddingEncoderCapability, dt as resolvePoolMemoryPolicy, ot as parseProcStatus, tt as hfModelUrl, yt as BaseAddon } from "../dist-C-_TL-I1.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import * as fs from "node:fs";
4
4
  import "node:fs";
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
3
3
  var e = {
4
4
  "@camstack/sdk": {
5
5
  name: "@camstack/sdk",
6
- version: "1.2.23",
6
+ version: "1.2.24",
7
7
  scope: ["default"],
8
8
  loaded: !1,
9
9
  from: "addon_pipeline_analytics_widgets",
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.87",
21
+ version: "1.2.88",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_pipeline_analytics_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.2.59",
36
+ version: "1.2.60",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_pipeline_analytics_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.87",
39
+ version: "1.2.88",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -45,7 +45,7 @@ async function r() {
45
45
  }
46
46
  },
47
47
  "@camstack/sdk": {
48
- version: "1.2.23",
48
+ version: "1.2.24",
49
49
  scope: "default",
50
50
  shareConfig: {
51
51
  singleton: !0,
@@ -81,7 +81,7 @@ async function r() {
81
81
  }
82
82
  },
83
83
  "@camstack/ui-library": {
84
- version: "1.2.59",
84
+ version: "1.2.60",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DYoWeRRt.js");
5
+ const require_dist = require("../dist-BdojoP7i.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -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({
@@ -30047,6 +30260,22 @@ function stillInstantMs(ranges) {
30047
30260
  if (best === void 0) return void 0;
30048
30261
  return Math.floor((best.startMs + best.endMs) / 2);
30049
30262
  }
30263
+ /**
30264
+ * The distinct object classes attached to a visit, most frequent first,
30265
+ * capped at {@link MAX_CLIP_LABELS}. Empty when nothing classified overlapped
30266
+ * — the caller then omits the field so a motion/audio-only visit keeps its
30267
+ * kind label instead of carrying an empty array every consumer length-checks.
30268
+ */
30269
+ function dominantLabels(attached) {
30270
+ const counts = /* @__PURE__ */ new Map();
30271
+ for (const a of attached) {
30272
+ if (a.kind !== "object") continue;
30273
+ const name = a.event.className;
30274
+ if (typeof name !== "string" || name.length === 0) continue;
30275
+ counts.set(name, (counts.get(name) ?? 0) + 1);
30276
+ }
30277
+ return [...counts.entries()].toSorted((x, y) => y[1] - x[1]).slice(0, 3).map(([name]) => name);
30278
+ }
30050
30279
  function visitsToClips(visits, events, pad, vouched) {
30051
30280
  const tagged = [
30052
30281
  ...events.object.map((event) => ({
@@ -30067,6 +30296,7 @@ function visitsToClips(visits, events, pad, vouched) {
30067
30296
  const kind = dominantKind(attached);
30068
30297
  const withThumb = attached.find((a) => a.kind === kind && a.event.mediaUrl && vouched.has(a.event.id)) ?? attached.find((a) => a.event.mediaUrl && vouched.has(a.event.id));
30069
30298
  const stillAtMs = stillInstantMs(visit.ranges);
30299
+ const labels = dominantLabels(attached);
30070
30300
  return {
30071
30301
  id: `analytics:${visit.startMs}:${visit.endMs}:${VISIT_EVENT_ID}`,
30072
30302
  source: "analytics",
@@ -30079,7 +30309,8 @@ function visitsToClips(visits, events, pad, vouched) {
30079
30309
  ...stillAtMs !== void 0 ? { stillAtMs } : {},
30080
30310
  eventIds: attached.slice(0, 24).map((a) => a.event.id),
30081
30311
  eventCount: attached.length,
30082
- holes: [...visit.holes]
30312
+ holes: [...visit.holes],
30313
+ ...labels.length > 0 ? { labels } : {}
30083
30314
  };
30084
30315
  }).toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
30085
30316
  }
@@ -33534,146 +33765,6 @@ async function resolveDefaultEventMedia(deps) {
33534
33765
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
33535
33766
  }
33536
33767
  //#endregion
33537
- //#region src/pipeline-analytics/pipeline/face-matcher.ts
33538
- /**
33539
- * Count enrolled samples per identity for probes of a matching model+dimension.
33540
- * Only identities meeting `minIdentitySamples` are eligible auto-match targets.
33541
- */
33542
- function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples) {
33543
- const counts = /* @__PURE__ */ new Map();
33544
- for (const s of gallery) {
33545
- if (s.modelId !== probeModelId) continue;
33546
- if (s.embedding.length !== probeDim) continue;
33547
- counts.set(s.identityId, (counts.get(s.identityId) ?? 0) + 1);
33548
- }
33549
- const eligible = /* @__PURE__ */ new Set();
33550
- for (const [id, count] of counts) if (count >= minIdentitySamples) eligible.add(id);
33551
- return eligible;
33552
- }
33553
- /**
33554
- * Match a probe embedding against the gallery. Only samples with the same
33555
- * `modelId` AND the same dimension are compared (model-version safety). Returns
33556
- * the best identity when its score ≥ threshold and it beats the best OTHER
33557
- * identity by ≥ margin; otherwise null.
33558
- */
33559
- function matchEmbedding(probe, gallery, opts) {
33560
- const probeVec = new Float32Array(probe.embedding);
33561
- const eligible = eligibleIdentities(gallery, probe.modelId, probe.embedding.length, opts.minIdentitySamples ?? 1);
33562
- const bestByIdentity = /* @__PURE__ */ new Map();
33563
- for (const s of gallery) {
33564
- if (s.modelId !== probe.modelId) continue;
33565
- if (s.embedding.length !== probe.embedding.length) continue;
33566
- if (!eligible.has(s.identityId)) continue;
33567
- const score = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
33568
- const prev = bestByIdentity.get(s.identityId);
33569
- if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
33570
- }
33571
- if (bestByIdentity.size === 0) return null;
33572
- let bestId = null;
33573
- let bestScore = -Infinity;
33574
- let secondScore = -Infinity;
33575
- for (const [id, score] of bestByIdentity) if (score > bestScore) {
33576
- secondScore = bestScore;
33577
- bestScore = score;
33578
- bestId = id;
33579
- } else if (score > secondScore) secondScore = score;
33580
- if (bestId === null || bestScore < opts.threshold) return null;
33581
- if (secondScore > -Infinity && bestScore - secondScore < opts.margin) return null;
33582
- return {
33583
- identityId: bestId,
33584
- score: bestScore
33585
- };
33586
- }
33587
- /**
33588
- * Assign at most one identity per track AND at most one track per identity for
33589
- * a single frame. Greedy by score: compute every candidate's full ranked match
33590
- * list, then repeatedly take the globally-highest (track, identity) pair whose
33591
- * track and identity are both still free, until none qualify. A losing track
33592
- * falls back to its next-best free identity ≥ threshold. Ties resolve by input order.
33593
- */
33594
- function assignUniquePerFrame(candidates, gallery, opts) {
33595
- const pairs = [];
33596
- candidates.forEach((c, trackIdx) => {
33597
- const probeVec = new Float32Array(c.embedding);
33598
- const eligible = eligibleIdentities(gallery, c.modelId, c.embedding.length, opts.minIdentitySamples ?? 1);
33599
- const bestByIdentity = /* @__PURE__ */ new Map();
33600
- for (const s of gallery) {
33601
- if (s.modelId !== c.modelId) continue;
33602
- if (s.embedding.length !== c.embedding.length) continue;
33603
- if (!eligible.has(s.identityId)) continue;
33604
- const score = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
33605
- const prev = bestByIdentity.get(s.identityId);
33606
- if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
33607
- }
33608
- for (const [identityId, score] of bestByIdentity) if (score >= opts.threshold) pairs.push({
33609
- trackIdx,
33610
- identityId,
33611
- score
33612
- });
33613
- });
33614
- pairs.sort((a, b) => b.score - a.score);
33615
- const takenTracks = /* @__PURE__ */ new Set();
33616
- const takenIdentities = /* @__PURE__ */ new Set();
33617
- const result = /* @__PURE__ */ new Map();
33618
- for (const p of pairs) {
33619
- if (takenTracks.has(p.trackIdx) || takenIdentities.has(p.identityId)) continue;
33620
- takenTracks.add(p.trackIdx);
33621
- takenIdentities.add(p.identityId);
33622
- result.set(candidates[p.trackIdx].trackId, {
33623
- identityId: p.identityId,
33624
- score: p.score
33625
- });
33626
- }
33627
- return result;
33628
- }
33629
- var EMPTY_AGGREGATE = Object.freeze({
33630
- assignedIdentityId: null,
33631
- confirmations: Object.freeze({})
33632
- });
33633
- /**
33634
- * Fold one frame's match into a track's aggregate. A null match leaves state
33635
- * untouched. An identity is assigned once its cumulative confirmation count
33636
- * reaches `confirmFrames`; once assigned, a different identity must independently
33637
- * reach `confirmFrames` to switch. `changed` is true only on a (re)assignment to
33638
- * a NEW identity, so callers fire the label update exactly once per transition.
33639
- *
33640
- * On each (re)assignment the confirmations record is reset to contain only the
33641
- * newly-assigned identity's count, so any challenger must independently re-reach
33642
- * `confirmFrames` from scratch after any assignment.
33643
- */
33644
- function updateTrackAggregate(prev, match, opts) {
33645
- if (match === null) return {
33646
- state: prev,
33647
- changed: false
33648
- };
33649
- const count = (prev.confirmations[match.identityId] ?? 0) + 1;
33650
- const confirmations = {
33651
- ...prev.confirmations,
33652
- [match.identityId]: count
33653
- };
33654
- if (count < opts.confirmFrames) return {
33655
- state: {
33656
- ...prev,
33657
- confirmations
33658
- },
33659
- changed: false
33660
- };
33661
- if (prev.assignedIdentityId === match.identityId) return {
33662
- state: {
33663
- ...prev,
33664
- confirmations
33665
- },
33666
- changed: false
33667
- };
33668
- return {
33669
- state: {
33670
- assignedIdentityId: match.identityId,
33671
- confirmations: { [match.identityId]: count }
33672
- },
33673
- changed: true
33674
- };
33675
- }
33676
- //#endregion
33677
33768
  //#region src/pipeline-analytics/face-recognizer.ts
33678
33769
  /** Attribution of an identity resolved by the in-process ArcFace matcher. */
33679
33770
  var FACE_RECOGNIZER_STEP_ID = "face-recognizer";
@@ -52378,6 +52469,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
52378
52469
  },
52379
52470
  renderEventMedia: (req) => this.renderEventMedia(req),
52380
52471
  publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
52472
+ viewerUrlBase: async () => pickArtifactBaseUrl(await this.ncEndpoints()),
52381
52473
  indexArtifact: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? {
52382
52474
  url: null,
52383
52475
  artifactId: null
@@ -1,4 +1,4 @@
1
- import { $ as encodeVectorBase64, A as RetrainStatusSchema, At as literal, B as TrackSourceSchema, C as NcSnoozeSchema, Ct as isDeviceScopedCap, D as OpsLogEntrySchema, Dt as array, E as NcTaxonomySchema, Et as _enum, F as SceneMonitorSchema, Ft as string, G as audioMetricsCapability, H as addonWidgetsSourceCapability, I as TIMELAPSE_DENSE_FLOOR_SEC, It as unknown, J as cosineSimilarity$1, K as audioModeOf, L as TimelapseRuleInputSchema, Lt as EventCategory, M as SCENE_DEFAULT_ANCHOR_THRESHOLD, Mt as object, N as SCENE_DEFAULT_UNCOVERED_POLICY, Nt as partialRecord, Ot as boolean, P as SCENE_DIVERGED, Pt as record, R as TimelapseRulePatchSchema, S as NcSnoozeInputSchema, St as hydrateSchema, T as NcSystemEventKindSchema, Tt as sleep, U as alarmPanelCapability, V as VISIT_MERGE_GAP_MS, W as assertTimelapseCadences, X as defineCustomActions, Y as customAction, Z as deriveRecordingMode, _ as NcRuleInputSchema, _t as zoneAnalyticsCapability, at as notificationRulesCapability, b as NcRuleTargetSchema, bt as DeviceType, c as EVENT_PAD_MS, ct as plateGalleryCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, et as faceGalleryCapability, f as NC_CONDITION_CATALOG, ft as sceneMonitorCapability, g as NcConditionDescriptorSchema, gt as videoclipsCapability, h as NC_TAXONOMY, ht as vectorDimFromBase64, i as DETECTION_MACRO_CLASSES, it as kebabToCamel, jt as number, k as RECORDING_EXPORT_MAX_READ_BYTES, kt as discriminatedUnion, l as LabelAttributionSchema, lt as readDeviceStateFrom, mt as systemEventFilterApplies, n as DEFAULT_EVENT_COLOR, nt as isDetectionMacroClass, o as DeclaredDevices, p as NC_DEFAULT_SNOOZE_MINUTES, pt as subKindsOf, q as buildEventKindDescriptor, rt as isScheduleActive, s as EVENT_KIND_BY_CAP, st as pipelineAnalyticsCapability, t as BaseDevice, u as MACRO_LABELS, ut as readTimelapseGeneratedAt, v as NcRulePatchSchema, vt as errMsg, w as NcSnoozeSuppressedSchema, wt as nodePin, x as NcScheduleSchema, xt as createEvent, y as NcRuleSchema, yt as BaseAddon, z as TimelapseRuleSchema } from "../dist-3QRyOemL.mjs";
1
+ import { $ as encodeVectorBase64, A as RetrainStatusSchema, At as literal, B as TrackSourceSchema, C as NcSnoozeSchema, Ct as isDeviceScopedCap, D as OpsLogEntrySchema, Dt as array, E as NcTaxonomySchema, Et as _enum, F as SceneMonitorSchema, Ft as string, G as audioMetricsCapability, H as addonWidgetsSourceCapability, I as TIMELAPSE_DENSE_FLOOR_SEC, It as unknown, J as cosineSimilarity$1, K as audioModeOf, L as TimelapseRuleInputSchema, Lt as EventCategory, M as SCENE_DEFAULT_ANCHOR_THRESHOLD, Mt as object, N as SCENE_DEFAULT_UNCOVERED_POLICY, Nt as partialRecord, Ot as boolean, P as SCENE_DIVERGED, Pt as record, R as TimelapseRulePatchSchema, S as NcSnoozeInputSchema, St as hydrateSchema, T as NcSystemEventKindSchema, Tt as sleep, U as alarmPanelCapability, V as VISIT_MERGE_GAP_MS, W as assertTimelapseCadences, X as defineCustomActions, Y as customAction, Z as deriveRecordingMode, _ as NcRuleInputSchema, _t as zoneAnalyticsCapability, at as notificationRulesCapability, b as NcRuleTargetSchema, bt as DeviceType, c as EVENT_PAD_MS, ct as plateGalleryCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, et as faceGalleryCapability, f as NC_CONDITION_CATALOG, ft as sceneMonitorCapability, g as NcConditionDescriptorSchema, gt as videoclipsCapability, h as NC_TAXONOMY, ht as vectorDimFromBase64, i as DETECTION_MACRO_CLASSES, it as kebabToCamel, jt as number, k as RECORDING_EXPORT_MAX_READ_BYTES, kt as discriminatedUnion, l as LabelAttributionSchema, lt as readDeviceStateFrom, mt as systemEventFilterApplies, n as DEFAULT_EVENT_COLOR, nt as isDetectionMacroClass, o as DeclaredDevices, p as NC_DEFAULT_SNOOZE_MINUTES, pt as subKindsOf, q as buildEventKindDescriptor, rt as isScheduleActive, s as EVENT_KIND_BY_CAP, st as pipelineAnalyticsCapability, t as BaseDevice, u as MACRO_LABELS, ut as readTimelapseGeneratedAt, v as NcRulePatchSchema, vt as errMsg, w as NcSnoozeSuppressedSchema, wt as nodePin, x as NcScheduleSchema, xt as createEvent, y as NcRuleSchema, yt as BaseAddon, z as TimelapseRuleSchema } from "../dist-C-_TL-I1.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -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({
@@ -29988,6 +30201,22 @@ function stillInstantMs(ranges) {
29988
30201
  if (best === void 0) return void 0;
29989
30202
  return Math.floor((best.startMs + best.endMs) / 2);
29990
30203
  }
30204
+ /**
30205
+ * The distinct object classes attached to a visit, most frequent first,
30206
+ * capped at {@link MAX_CLIP_LABELS}. Empty when nothing classified overlapped
30207
+ * — the caller then omits the field so a motion/audio-only visit keeps its
30208
+ * kind label instead of carrying an empty array every consumer length-checks.
30209
+ */
30210
+ function dominantLabels(attached) {
30211
+ const counts = /* @__PURE__ */ new Map();
30212
+ for (const a of attached) {
30213
+ if (a.kind !== "object") continue;
30214
+ const name = a.event.className;
30215
+ if (typeof name !== "string" || name.length === 0) continue;
30216
+ counts.set(name, (counts.get(name) ?? 0) + 1);
30217
+ }
30218
+ return [...counts.entries()].toSorted((x, y) => y[1] - x[1]).slice(0, 3).map(([name]) => name);
30219
+ }
29991
30220
  function visitsToClips(visits, events, pad, vouched) {
29992
30221
  const tagged = [
29993
30222
  ...events.object.map((event) => ({
@@ -30008,6 +30237,7 @@ function visitsToClips(visits, events, pad, vouched) {
30008
30237
  const kind = dominantKind(attached);
30009
30238
  const withThumb = attached.find((a) => a.kind === kind && a.event.mediaUrl && vouched.has(a.event.id)) ?? attached.find((a) => a.event.mediaUrl && vouched.has(a.event.id));
30010
30239
  const stillAtMs = stillInstantMs(visit.ranges);
30240
+ const labels = dominantLabels(attached);
30011
30241
  return {
30012
30242
  id: `analytics:${visit.startMs}:${visit.endMs}:${VISIT_EVENT_ID}`,
30013
30243
  source: "analytics",
@@ -30020,7 +30250,8 @@ function visitsToClips(visits, events, pad, vouched) {
30020
30250
  ...stillAtMs !== void 0 ? { stillAtMs } : {},
30021
30251
  eventIds: attached.slice(0, 24).map((a) => a.event.id),
30022
30252
  eventCount: attached.length,
30023
- holes: [...visit.holes]
30253
+ holes: [...visit.holes],
30254
+ ...labels.length > 0 ? { labels } : {}
30024
30255
  };
30025
30256
  }).toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
30026
30257
  }
@@ -33474,146 +33705,6 @@ async function resolveDefaultEventMedia(deps) {
33474
33705
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
33475
33706
  }
33476
33707
  //#endregion
33477
- //#region src/pipeline-analytics/pipeline/face-matcher.ts
33478
- /**
33479
- * Count enrolled samples per identity for probes of a matching model+dimension.
33480
- * Only identities meeting `minIdentitySamples` are eligible auto-match targets.
33481
- */
33482
- function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples) {
33483
- const counts = /* @__PURE__ */ new Map();
33484
- for (const s of gallery) {
33485
- if (s.modelId !== probeModelId) continue;
33486
- if (s.embedding.length !== probeDim) continue;
33487
- counts.set(s.identityId, (counts.get(s.identityId) ?? 0) + 1);
33488
- }
33489
- const eligible = /* @__PURE__ */ new Set();
33490
- for (const [id, count] of counts) if (count >= minIdentitySamples) eligible.add(id);
33491
- return eligible;
33492
- }
33493
- /**
33494
- * Match a probe embedding against the gallery. Only samples with the same
33495
- * `modelId` AND the same dimension are compared (model-version safety). Returns
33496
- * the best identity when its score ≥ threshold and it beats the best OTHER
33497
- * identity by ≥ margin; otherwise null.
33498
- */
33499
- function matchEmbedding(probe, gallery, opts) {
33500
- const probeVec = new Float32Array(probe.embedding);
33501
- const eligible = eligibleIdentities(gallery, probe.modelId, probe.embedding.length, opts.minIdentitySamples ?? 1);
33502
- const bestByIdentity = /* @__PURE__ */ new Map();
33503
- for (const s of gallery) {
33504
- if (s.modelId !== probe.modelId) continue;
33505
- if (s.embedding.length !== probe.embedding.length) continue;
33506
- if (!eligible.has(s.identityId)) continue;
33507
- const score = cosineSimilarity$1(probeVec, new Float32Array(s.embedding));
33508
- const prev = bestByIdentity.get(s.identityId);
33509
- if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
33510
- }
33511
- if (bestByIdentity.size === 0) return null;
33512
- let bestId = null;
33513
- let bestScore = -Infinity;
33514
- let secondScore = -Infinity;
33515
- for (const [id, score] of bestByIdentity) if (score > bestScore) {
33516
- secondScore = bestScore;
33517
- bestScore = score;
33518
- bestId = id;
33519
- } else if (score > secondScore) secondScore = score;
33520
- if (bestId === null || bestScore < opts.threshold) return null;
33521
- if (secondScore > -Infinity && bestScore - secondScore < opts.margin) return null;
33522
- return {
33523
- identityId: bestId,
33524
- score: bestScore
33525
- };
33526
- }
33527
- /**
33528
- * Assign at most one identity per track AND at most one track per identity for
33529
- * a single frame. Greedy by score: compute every candidate's full ranked match
33530
- * list, then repeatedly take the globally-highest (track, identity) pair whose
33531
- * track and identity are both still free, until none qualify. A losing track
33532
- * falls back to its next-best free identity ≥ threshold. Ties resolve by input order.
33533
- */
33534
- function assignUniquePerFrame(candidates, gallery, opts) {
33535
- const pairs = [];
33536
- candidates.forEach((c, trackIdx) => {
33537
- const probeVec = new Float32Array(c.embedding);
33538
- const eligible = eligibleIdentities(gallery, c.modelId, c.embedding.length, opts.minIdentitySamples ?? 1);
33539
- const bestByIdentity = /* @__PURE__ */ new Map();
33540
- for (const s of gallery) {
33541
- if (s.modelId !== c.modelId) continue;
33542
- if (s.embedding.length !== c.embedding.length) continue;
33543
- if (!eligible.has(s.identityId)) continue;
33544
- const score = cosineSimilarity$1(probeVec, new Float32Array(s.embedding));
33545
- const prev = bestByIdentity.get(s.identityId);
33546
- if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
33547
- }
33548
- for (const [identityId, score] of bestByIdentity) if (score >= opts.threshold) pairs.push({
33549
- trackIdx,
33550
- identityId,
33551
- score
33552
- });
33553
- });
33554
- pairs.sort((a, b) => b.score - a.score);
33555
- const takenTracks = /* @__PURE__ */ new Set();
33556
- const takenIdentities = /* @__PURE__ */ new Set();
33557
- const result = /* @__PURE__ */ new Map();
33558
- for (const p of pairs) {
33559
- if (takenTracks.has(p.trackIdx) || takenIdentities.has(p.identityId)) continue;
33560
- takenTracks.add(p.trackIdx);
33561
- takenIdentities.add(p.identityId);
33562
- result.set(candidates[p.trackIdx].trackId, {
33563
- identityId: p.identityId,
33564
- score: p.score
33565
- });
33566
- }
33567
- return result;
33568
- }
33569
- var EMPTY_AGGREGATE = Object.freeze({
33570
- assignedIdentityId: null,
33571
- confirmations: Object.freeze({})
33572
- });
33573
- /**
33574
- * Fold one frame's match into a track's aggregate. A null match leaves state
33575
- * untouched. An identity is assigned once its cumulative confirmation count
33576
- * reaches `confirmFrames`; once assigned, a different identity must independently
33577
- * reach `confirmFrames` to switch. `changed` is true only on a (re)assignment to
33578
- * a NEW identity, so callers fire the label update exactly once per transition.
33579
- *
33580
- * On each (re)assignment the confirmations record is reset to contain only the
33581
- * newly-assigned identity's count, so any challenger must independently re-reach
33582
- * `confirmFrames` from scratch after any assignment.
33583
- */
33584
- function updateTrackAggregate(prev, match, opts) {
33585
- if (match === null) return {
33586
- state: prev,
33587
- changed: false
33588
- };
33589
- const count = (prev.confirmations[match.identityId] ?? 0) + 1;
33590
- const confirmations = {
33591
- ...prev.confirmations,
33592
- [match.identityId]: count
33593
- };
33594
- if (count < opts.confirmFrames) return {
33595
- state: {
33596
- ...prev,
33597
- confirmations
33598
- },
33599
- changed: false
33600
- };
33601
- if (prev.assignedIdentityId === match.identityId) return {
33602
- state: {
33603
- ...prev,
33604
- confirmations
33605
- },
33606
- changed: false
33607
- };
33608
- return {
33609
- state: {
33610
- assignedIdentityId: match.identityId,
33611
- confirmations: { [match.identityId]: count }
33612
- },
33613
- changed: true
33614
- };
33615
- }
33616
- //#endregion
33617
33708
  //#region src/pipeline-analytics/face-recognizer.ts
33618
33709
  /** Attribution of an identity resolved by the in-process ArcFace matcher. */
33619
33710
  var FACE_RECOGNIZER_STEP_ID = "face-recognizer";
@@ -52318,6 +52409,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
52318
52409
  },
52319
52410
  renderEventMedia: (req) => this.renderEventMedia(req),
52320
52411
  publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
52412
+ viewerUrlBase: async () => pickArtifactBaseUrl(await this.ncEndpoints()),
52321
52413
  indexArtifact: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? {
52322
52414
  url: null,
52323
52415
  artifactId: null
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-C4-ljPpt.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CeFPqm3h.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.93",
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",