@camstack/addon-post-analysis 1.2.94 → 1.2.96

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.
@@ -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-C-_TL-I1.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-ecY_-nDx.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";
@@ -5555,24 +5555,39 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5555
5555
  }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
5556
5556
  }
5557
5557
  //#endregion
5558
- //#region src/notification-center/audio-still.ts
5558
+ //#region src/notification-center/still-shelf.ts
5559
5559
  /**
5560
- * The audio still shelf — the PHOTOGRAPH a sound rule's notification carries.
5560
+ * The still shelf — the PHOTOGRAPH a notification carries when nothing it can
5561
+ * name owns a frame.
5561
5562
  *
5562
- * ## Why a sound needs one at all
5563
+ * ## Why a trigger needs one at all
5563
5564
  *
5564
- * The attachment ladder resolves media by OWNER, and every other trigger has
5565
- * one: an object or package event owns its crops, a closed track owns its best
5566
- * shot, a doorbell press owns the marker track the same press projected
5565
+ * The attachment ladder resolves media by OWNER, and most triggers have one: an
5566
+ * object or package event owns its crops, a closed track owns its best shot, a
5567
+ * doorbell press owns the marker track the same press projected
5567
5568
  * (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
5568
- * counted (`chooseOccupancyMediaOwner`). An audio match owns nothing. Nothing
5569
- * was boxed, nothing was tracked, and — deliberately — nothing is persisted at
5570
- * all: a confirmed window is a claim about sound that has already stopped, and
5571
- * `event-intake.ts` states why replaying it later would be wrong.
5569
+ * counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
5570
+ * same reason in both cases — the subject is an ABSENCE:
5571
+ *
5572
+ * - an **audio** match: nothing was boxed, nothing was tracked, and —
5573
+ * deliberately — nothing is persisted at all. A confirmed window is a claim
5574
+ * about sound that has already stopped, and `event-intake.ts` states why
5575
+ * replaying it later would be wrong.
5576
+ * - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
5577
+ * whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
5578
+ * nobody and the ladder logs `no still could be resolved … owners=[]`.
5572
5579
  *
5573
5580
  * So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
5574
- * of the match. Not of the sound — of what the camera could see while it was
5575
- * heard.
5581
+ * of the trigger. Not of the sound, not of the object that left — of what the
5582
+ * camera can see now that it happened. For a freed parking space that is
5583
+ * exactly the answer the operator wants: the space, empty.
5584
+ *
5585
+ * **One shelf, not two.** The mechanism is identical down to the reuse window,
5586
+ * and the only thing that differs between the two triggers is the GATE deciding
5587
+ * that a photograph is owed at all — which belongs at the trigger site, where
5588
+ * the rules are, and not here. A twin module would be a second derivation of
5589
+ * one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
5590
+ * can say which absence they are about.
5576
5591
  *
5577
5592
  * ## Three properties, and each one is a decision
5578
5593
  *
@@ -5580,27 +5595,30 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5580
5595
  * TTL that covers the outbox's whole retry horizon — and nowhere else. The
5581
5596
  * alternative was the doorbell's: materialise a synthetic marker track through
5582
5597
  * `SyntheticTrackMaterializer` and let the notification name it. That would put
5583
- * a durable Track on the camera's timeline for every confirmed window, feeding
5584
- * the digest's `listTracks`, retention, and the audio-marker feature's own
5585
- * operator ceilings (`audio-marker-projector.ts` exists precisely to bound how
5586
- * many audio markers a camera may emit). A notification must not manufacture
5587
- * timeline history as a side effect of wanting a picture.
5588
- *
5589
- * **The capture STARTS immediately and is never awaited.** A sound is transient
5590
- * — a scream is over before a snapshot round-trip completes — so the fetch is
5591
- * kicked off at the confirmation, before the rule evaluation runs, and the
5592
- * owner id is minted synchronously so the outbox row can name it. The bytes
5593
- * land while the row waits in the queue, and the dispatcher's existing bounded
5594
- * still-wait (or the pause its own footage render already costs) picks them up.
5595
- * A camera that never answers costs the picture and never the notification.
5596
- *
5597
- * **Two confirmations seconds apart share ONE capture.** A barking dog confirms
5598
+ * a durable Track on the camera's timeline for every confirmed window and every
5599
+ * emptied zone, feeding the digest's `listTracks`, retention, and the audio-
5600
+ * marker feature's own operator ceilings (`audio-marker-projector.ts` exists
5601
+ * precisely to bound how many audio markers a camera may emit). A notification
5602
+ * must not manufacture timeline history as a side effect of wanting a picture.
5603
+ *
5604
+ * **The capture STARTS immediately and is never awaited.** The subject is
5605
+ * transient — a scream is over before a snapshot round-trip completes, and a
5606
+ * freed space is about to be taken by the next car — so the fetch is kicked off
5607
+ * at the trigger, before the rule evaluation runs, and the owner id is minted
5608
+ * synchronously so the outbox row can name it. The bytes land while the row
5609
+ * waits in the queue, and the dispatcher's existing bounded still-wait (or the
5610
+ * pause its own footage render already costs) picks them up. A camera that
5611
+ * never answers costs the picture and never the notification.
5612
+ *
5613
+ * **Two triggers seconds apart share ONE capture.** A barking dog confirms
5598
5614
  * repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
5599
5615
  * rule's cooldown is its only brake, and the cooldown is applied AFTER this.
5600
5616
  * Without a reuse window this would photograph a camera at whatever rate the
5601
- * sound happens to occur. Each confirmation still gets its OWN owner id, so two
5617
+ * sound happens to occur. Each trigger still gets its OWN owner id, so two
5602
5618
  * outbox rows are never mistaken for one subject; they merely point at the same
5603
- * frame, which is the truth — the scene did not change in ten seconds.
5619
+ * frame, which is the truth — the scene did not change in ten seconds. The
5620
+ * window is per CAMERA and trigger-agnostic for the same reason: a sound and an
5621
+ * emptied zone ten seconds apart are two claims about one scene.
5604
5622
  *
5605
5623
  * Nothing here is silent: a capture that lands and a camera that refuses each
5606
5624
  * emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
@@ -5608,13 +5626,13 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5608
5626
  */
5609
5627
  /**
5610
5628
  * The owner-id namespace. It is what routes a lookup here instead of to the
5611
- * media store, and it is the reason the dispatcher needs no audio branch —
5612
- * `getMediaForOwner` answers for both under one signature.
5629
+ * media store, and it is the reason the dispatcher needs no audio or occupancy
5630
+ * branch — `getMediaForOwner` answers for all of them under one signature.
5613
5631
  */
5614
- var NC_AUDIO_STILL_PREFIX = "nc-audio-still:";
5632
+ var NC_STILL_SHELF_PREFIX = "nc-still:";
5615
5633
  /** True for an owner id this shelf minted. */
5616
- function isAudioStillId(id) {
5617
- return id.startsWith(NC_AUDIO_STILL_PREFIX);
5634
+ function isStillShelfId(id) {
5635
+ return id.startsWith(NC_STILL_SHELF_PREFIX);
5618
5636
  }
5619
5637
  /**
5620
5638
  * How long the bytes are held.
@@ -5624,16 +5642,16 @@ function isAudioStillId(id) {
5624
5642
  * ships text-only, which is the correct degradation for a photograph of a scene
5625
5643
  * that is a quarter of an hour stale anyway.
5626
5644
  */
5627
- var NC_AUDIO_STILL_TTL_MS = 15 * 6e4;
5645
+ var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
5628
5646
  /** A hanging snapshot cap must not pin a capture slot forever. */
5629
5647
  var SNAPSHOT_TIMEOUT_MS = 8e3;
5630
5648
  /**
5631
- * Hard bound on held captures. Reached only if every camera on the hub confirms
5632
- * an audio rule inside one TTL; the oldest is dropped first, which costs a
5649
+ * Hard bound on held captures. Reached only if every camera on the hub triggers
5650
+ * one inside a single TTL; the oldest is dropped first, which costs a
5633
5651
  * fifteen-minute-old picture nobody is waiting for.
5634
5652
  */
5635
5653
  var MAX_CAPTURES = 64;
5636
- var NcAudioStillShelf = class {
5654
+ var NcStillShelf = class {
5637
5655
  deps;
5638
5656
  /** captureId → the photograph. */
5639
5657
  captures = /* @__PURE__ */ new Map();
@@ -5645,14 +5663,14 @@ var NcAudioStillShelf = class {
5645
5663
  this.deps = deps;
5646
5664
  }
5647
5665
  /**
5648
- * Photograph `deviceId` for a match at `atMs`, and return the owner id the
5649
- * subject should name. SYNCHRONOUS by contract: the caller is on the audio
5650
- * confirmation path and the outbox row is built from what this returns.
5666
+ * Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
5667
+ * subject should name. SYNCHRONOUS by contract: the caller is on the trigger
5668
+ * path and the outbox row is built from what this returns.
5651
5669
  */
5652
- capture(deviceId, atMs) {
5670
+ capture(deviceId, atMs, trigger) {
5653
5671
  const now = this.deps.now();
5654
5672
  this.prune(now);
5655
- const ownerId = `${NC_AUDIO_STILL_PREFIX}${randomUUID()}`;
5673
+ const ownerId = `${NC_STILL_SHELF_PREFIX}${randomUUID()}`;
5656
5674
  const recent = this.newest.get(deviceId);
5657
5675
  if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
5658
5676
  this.owners.set(ownerId, recent.captureId);
@@ -5661,8 +5679,9 @@ var NcAudioStillShelf = class {
5661
5679
  const captureId = randomUUID();
5662
5680
  this.captures.set(captureId, {
5663
5681
  deviceId,
5682
+ trigger,
5664
5683
  startedAt: now,
5665
- expiresAt: now + NC_AUDIO_STILL_TTL_MS,
5684
+ expiresAt: now + NC_STILL_SHELF_TTL_MS,
5666
5685
  files: []
5667
5686
  });
5668
5687
  this.newest.set(deviceId, {
@@ -5670,7 +5689,7 @@ var NcAudioStillShelf = class {
5670
5689
  at: now
5671
5690
  });
5672
5691
  this.owners.set(ownerId, captureId);
5673
- this.fetch(captureId, deviceId, atMs);
5692
+ this.fetch(captureId, deviceId, atMs, trigger);
5674
5693
  return ownerId;
5675
5694
  }
5676
5695
  /**
@@ -5706,11 +5725,11 @@ var NcAudioStillShelf = class {
5706
5725
  this.owners.clear();
5707
5726
  this.newest.clear();
5708
5727
  }
5709
- async fetch(captureId, deviceId, atMs) {
5728
+ async fetch(captureId, deviceId, atMs, trigger) {
5710
5729
  try {
5711
5730
  const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
5712
5731
  if (shot === null || shot.base64.length === 0) {
5713
- this.reportMiss(deviceId, "the camera returned no snapshot");
5732
+ this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
5714
5733
  return;
5715
5734
  }
5716
5735
  const raw = Buffer.from(shot.base64, "base64");
@@ -5722,17 +5741,18 @@ var NcAudioStillShelf = class {
5722
5741
  if (held === void 0) return;
5723
5742
  this.captures.set(captureId, {
5724
5743
  ...held,
5725
- files: audioStillMedia(data, atMs)
5744
+ files: stillShelfMedia(data, atMs)
5726
5745
  });
5727
- this.deps.logger.info("audio still captured", {
5746
+ this.deps.logger.info(`${trigger} still captured`, {
5728
5747
  tags: { deviceId },
5729
5748
  meta: {
5749
+ trigger,
5730
5750
  bytes: data.byteLength,
5731
5751
  tookMs: this.deps.now() - held.startedAt
5732
5752
  }
5733
5753
  });
5734
5754
  } catch (err) {
5735
- this.reportMiss(deviceId, err instanceof Error ? err.message : String(err));
5755
+ this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
5736
5756
  }
5737
5757
  }
5738
5758
  /**
@@ -5740,10 +5760,13 @@ var NcAudioStillShelf = class {
5740
5760
  * on a notification they DID receive, so it is a warn and it carries the
5741
5761
  * camera — the only key the question is ever asked with.
5742
5762
  */
5743
- reportMiss(deviceId, reason) {
5744
- this.deps.logger.warn("the camera did not answer the audio still — this notification ships text-only", {
5763
+ reportMiss(deviceId, trigger, reason) {
5764
+ this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
5745
5765
  tags: { deviceId },
5746
- meta: { reason }
5766
+ meta: {
5767
+ trigger,
5768
+ reason
5769
+ }
5747
5770
  });
5748
5771
  }
5749
5772
  };
@@ -5758,10 +5781,10 @@ var NcAudioStillShelf = class {
5758
5781
  * so. Inventing a centre crop would answer a different question from the one
5759
5782
  * the operator asked.
5760
5783
  */
5761
- function audioStillMedia(data, timestamp) {
5784
+ function stillShelfMedia(data, timestamp) {
5762
5785
  const base64 = data.toString("base64");
5763
5786
  const file = (kind) => ({
5764
- key: `${NC_AUDIO_STILL_PREFIX}${kind}`,
5787
+ key: `${NC_STILL_SHELF_PREFIX}${kind}`,
5765
5788
  kind,
5766
5789
  base64,
5767
5790
  sizeBytes: data.byteLength,
@@ -6398,6 +6421,16 @@ var NcDeviceStateCache = class {
6398
6421
  }
6399
6422
  };
6400
6423
  //#endregion
6424
+ //#region src/notification-center/default-tap-url.ts
6425
+ /** The viewer's hosted base path under the hub. */
6426
+ var VIEWER_BASE_PATH = "/viewer/camstack";
6427
+ function defaultTapUrl(input) {
6428
+ if (input.systemEvent === true) return null;
6429
+ if (input.baseUrl === null || input.baseUrl.length === 0) return null;
6430
+ if (!Number.isInteger(input.deviceId) || input.deviceId <= 0) return null;
6431
+ return `${input.baseUrl.replace(/\/+$/, "")}${VIEWER_BASE_PATH}/camera/${String(input.deviceId)}?at=${String(input.timestampMs)}`;
6432
+ }
6433
+ //#endregion
6401
6434
  //#region src/shared/frame/box-drawer.ts
6402
6435
  var DEFAULT_COLOR = DEFAULT_EVENT_COLOR;
6403
6436
  var DEFAULT_QUALITY = 80;
@@ -7417,7 +7450,7 @@ function subjectFromSensorEvent(ev, markerTrackId) {
7417
7450
  * (see the {@link evaluateRule} audio gate + the matchers below).
7418
7451
  *
7419
7452
  * `stillOwnerId` is the audio still shelf's owner id — the photograph of the
7420
- * camera taken at the match (`audio-still.ts`). It rides `trackId` because that
7453
+ * camera taken at the match (`still-shelf.ts`). It rides `trackId` because that
7421
7454
  * is the field the attachment ladder reads, exactly as a doorbell press rides
7422
7455
  * its marker track's id; nothing else about the subject is track-scoped, and
7423
7456
  * `buildEntries` keys an audio row on its record id, never on this. Absent when
@@ -7509,10 +7542,18 @@ function audioSubjectFor(hit, spec) {
7509
7542
  * chosen at commit (`chooseOccupancyMediaOwner`) — rides on `trackId`, and that
7510
7543
  * is the whole of what gives an occupancy notification a picture: the ordinary
7511
7544
  * media-owner ladder resolves it, exactly as it resolves a doorbell's marker
7512
- * track. Absent when the scope held no member (a became-free edge) ⇒ text-only,
7513
- * by design.
7514
- */
7515
- function subjectFromOccupancyEvent(edge) {
7545
+ * track.
7546
+ *
7547
+ * `stillOwnerId` is the SECOND source of that picture and the only one an
7548
+ * emptied scope can have: with no member left to name — "posto libero", where
7549
+ * the object whose departure is the news has gone — the trigger site photographs
7550
+ * the camera and holds the bytes on the still shelf (`still-shelf.ts`), exactly
7551
+ * as an audio match does. The member track always WINS: it is a picture of the
7552
+ * counted object, and a photograph is only ever the stand-in for not having one.
7553
+ * Neither present (no snapshot cap on this node) ⇒ text-only, as before.
7554
+ */
7555
+ function subjectFromOccupancyEvent(edge, stillOwnerId) {
7556
+ const mediaOwner = edge.mediaTrackId ?? stillOwnerId;
7516
7557
  return {
7517
7558
  kind: "occupancy-event",
7518
7559
  recordId: `occ:${edge.deviceId}:${edge.zoneId ?? "@frame"}:${edge.className ?? "@all"}:t${edge.threshold}:${edge.occupied ? "occ" : "free"}:${edge.timestamp}`,
@@ -7520,17 +7561,28 @@ function subjectFromOccupancyEvent(edge) {
7520
7561
  timestamp: edge.timestamp,
7521
7562
  classNames: [edge.className ?? "occupancy"],
7522
7563
  zones: [],
7523
- ...edge.mediaTrackId !== void 0 ? { trackId: edge.mediaTrackId } : {},
7564
+ ...mediaOwner !== void 0 ? { trackId: mediaOwner } : {},
7524
7565
  source: "pipeline",
7525
- occupancy: {
7526
- ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
7527
- ...edge.zoneName !== void 0 ? { zoneName: edge.zoneName } : {},
7528
- ...edge.className !== void 0 ? { className: edge.className } : {},
7529
- count: edge.count,
7530
- previousCount: edge.previousCount,
7531
- occupied: edge.occupied,
7532
- threshold: edge.threshold
7533
- }
7566
+ occupancy: occupancySubjectFromEdge(edge)
7567
+ };
7568
+ }
7569
+ /**
7570
+ * The scope + level half of an occupancy subject, ALONE.
7571
+ *
7572
+ * Split out for one reason: the still-shelf gate must ask `matchesOccupancy`
7573
+ * whether a rule would fire on an edge BEFORE the subject that would carry the
7574
+ * answer exists (the owner id it mints is an input to that subject). Sharing
7575
+ * this keeps the gate reading the same numbers the verdict will.
7576
+ */
7577
+ function occupancySubjectFromEdge(edge) {
7578
+ return {
7579
+ ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
7580
+ ...edge.zoneName !== void 0 ? { zoneName: edge.zoneName } : {},
7581
+ ...edge.className !== void 0 ? { className: edge.className } : {},
7582
+ count: edge.count,
7583
+ previousCount: edge.previousCount,
7584
+ occupied: edge.occupied,
7585
+ threshold: edge.threshold
7534
7586
  };
7535
7587
  }
7536
7588
  /** Build the subject for a `package-event` evaluation (a persisted `package`
@@ -7657,6 +7709,13 @@ function matchesAudio(cond, s) {
7657
7709
  }
7658
7710
  return false;
7659
7711
  }
7712
+ /**
7713
+ * Exported for ONE caller: the still-shelf gate in the centre, which must know
7714
+ * — before the outbox row exists — whether any enabled rule would fire on THIS
7715
+ * edge and wants a picture for it. Sharing the predicate is what keeps that gate
7716
+ * from becoming a second, drifting opinion about which edge belongs to which
7717
+ * rule. It is pure and reads nothing.
7718
+ */
7660
7719
  function matchesOccupancy(occ, s) {
7661
7720
  if ((occ.zoneId ?? void 0) !== (s.zoneId ?? void 0)) return false;
7662
7721
  if ((occ.className ?? void 0) !== (s.className ?? void 0)) return false;
@@ -9255,12 +9314,19 @@ var NcDispatcher = class {
9255
9314
  }) : rendered;
9256
9315
  const attachments = [...await this.withArtifactUrls(entry, await this.resolveAttachments(entry)), ...frozenArtifactAttachments(entry)];
9257
9316
  const params = pickParams(entry.payload.params);
9317
+ const tapUrl = params.clickUrl === void 0 && this.deps.viewerUrlBase !== void 0 ? defaultTapUrl({
9318
+ deviceId: systemEvent !== void 0 ? 0 : subject.deviceId,
9319
+ timestampMs: subject.timestamp,
9320
+ baseUrl: await this.deps.viewerUrlBase().catch(() => null),
9321
+ systemEvent: systemEvent !== void 0
9322
+ }) : null;
9258
9323
  const actions = await this.mintActions(entry, target);
9259
9324
  return {
9260
9325
  body,
9261
9326
  title,
9262
9327
  priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
9263
9328
  ...params,
9329
+ ...tapUrl !== null ? { clickUrl: tapUrl } : {},
9264
9330
  tag: entry.payload.group?.key ?? entry.ruleId,
9265
9331
  ...systemEvent !== void 0 ? systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {} : { deviceId: subject.deviceId },
9266
9332
  ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
@@ -9857,7 +9923,7 @@ function incomingFromSensorEvent(event, markerTrackId) {
9857
9923
  * Audio-event persist (classified episode).
9858
9924
  *
9859
9925
  * `stillOwnerId` is the audio still shelf's owner id for the photograph taken
9860
- * at this episode (`audio-still.ts`). Absent when no rule that could fire asked
9926
+ * at this episode (`still-shelf.ts`). Absent when no rule that could fire asked
9861
9927
  * for a picture — the same shape `incomingFromSensorEvent` carries its marker.
9862
9928
  */
9863
9929
  function incomingFromAudioEvent(event, stillOwnerId) {
@@ -9938,10 +10004,16 @@ function audioHitMeta(hit) {
9938
10004
  samples: window.samples
9939
10005
  };
9940
10006
  }
9941
- /** A committed ZoneAnalytics occupancy edge. */
9942
- function incomingFromOccupancyEdge(edge) {
10007
+ /**
10008
+ * A committed ZoneAnalytics occupancy edge.
10009
+ *
10010
+ * `stillOwnerId` — see {@link subjectFromOccupancyEvent}. An edge whose scope is
10011
+ * empty owns no member track, so the only picture it can carry is a photograph
10012
+ * of its camera taken at the edge, held on the still shelf under this id.
10013
+ */
10014
+ function incomingFromOccupancyEdge(edge, stillOwnerId) {
9943
10015
  return {
9944
- subject: subjectFromOccupancyEvent(edge),
10016
+ subject: subjectFromOccupancyEvent(edge, stillOwnerId),
9945
10017
  kind: "occupancy-event",
9946
10018
  origin: "pipeline",
9947
10019
  log: () => ({
@@ -16055,7 +16127,7 @@ function isSyntheticId(id) {
16055
16127
  * - and, since 2026-08-15, a `trackId` for both AUDIO kinds: a sound owns no
16056
16128
  * frame, so production photographs the camera at the match and holds the
16057
16129
  * bytes on the still shelf under an owner id the subject names
16058
- * (`audio-still.ts`). The tester's RAM-held frame stands in for exactly that
16130
+ * (`still-shelf.ts`). The tester's RAM-held frame stands in for exactly that
16059
16131
  * photograph, and — unlike every other kind here — it is taken the same way
16060
16132
  * production takes it, at the same moment, from the same cap.
16061
16133
  *
@@ -18722,11 +18794,12 @@ var NotificationCenter = class NotificationCenter {
18722
18794
  */
18723
18795
  syntheticMedia = /* @__PURE__ */ new Map();
18724
18796
  /**
18725
- * The photograph a SOUND rule's notification carries — see `audio-still.ts`.
18726
- * Null when this wiring has no snapshot cap, which leaves every audio
18727
- * notification text-only rather than broken.
18797
+ * The photograph a notification carries when the subject it is about is an
18798
+ * ABSENCE — a sound that owns no frame, an emptied occupancy scope that owns
18799
+ * no member. See `still-shelf.ts`. Null when this wiring has no snapshot cap,
18800
+ * which leaves those notifications text-only rather than broken.
18728
18801
  */
18729
- audioStills;
18802
+ stills;
18730
18803
  /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
18731
18804
  lastNoMatchReportAt = /* @__PURE__ */ new Map();
18732
18805
  noMatchSuppressed = /* @__PURE__ */ new Map();
@@ -19023,9 +19096,9 @@ var NotificationCenter = class NotificationCenter {
19023
19096
  this.logger = deps.logger;
19024
19097
  this.now = deps.now ?? (() => Date.now());
19025
19098
  const getSnapshot = deps.getSnapshot;
19026
- this.audioStills = getSnapshot !== void 0 ? new NcAudioStillShelf({
19099
+ this.stills = getSnapshot !== void 0 ? new NcStillShelf({
19027
19100
  getSnapshot,
19028
- logger: this.logger.child("audio-still"),
19101
+ logger: this.logger.child("still-shelf"),
19029
19102
  now: this.now
19030
19103
  }) : null;
19031
19104
  this.rules = new NcRuleStore({
@@ -19115,8 +19188,8 @@ var NotificationCenter = class NotificationCenter {
19115
19188
  getMediaForOwner: async (ownerKind, ownerId) => {
19116
19189
  const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
19117
19190
  if (held !== void 0) return held.files;
19118
- const audio = isAudioStillId(ownerId) ? this.audioStills?.get(ownerId) : void 0;
19119
- if (audio !== void 0) return audio;
19191
+ const shelved = isStillShelfId(ownerId) ? this.stills?.get(ownerId) : void 0;
19192
+ if (shelved !== void 0) return shelved;
19120
19193
  return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
19121
19194
  },
19122
19195
  ...deps.now !== void 0 ? { now: deps.now } : {},
@@ -19126,7 +19199,8 @@ var NotificationCenter = class NotificationCenter {
19126
19199
  confirmGate: (input) => confirmGate.judge(input),
19127
19200
  confirmStats: () => confirmGate.stats()
19128
19201
  } : {},
19129
- releaseCooldown: (input) => this.releaseCooldown(input)
19202
+ releaseCooldown: (input) => this.releaseCooldown(input),
19203
+ ...deps.dispatcher.viewerUrlBase !== void 0 ? { viewerUrlBase: deps.dispatcher.viewerUrlBase } : {}
19130
19204
  });
19131
19205
  this.outbox = new NcOutbox({
19132
19206
  store: deps.store,
@@ -19321,7 +19395,7 @@ var NotificationCenter = class NotificationCenter {
19321
19395
  }
19322
19396
  this.timelapseScheduler?.stop();
19323
19397
  this.summaryProducer?.stop();
19324
- this.audioStills?.clear();
19398
+ this.stills?.clear();
19325
19399
  this.addonUpdates.dispose();
19326
19400
  this.livenessHoldoff.dispose();
19327
19401
  this.evaluationActive = false;
@@ -19673,14 +19747,14 @@ var NotificationCenter = class NotificationCenter {
19673
19747
  * direction that DESTROYS the media.
19674
19748
  */
19675
19749
  audioStillOwner(deviceId, atMs, mode) {
19676
- const shelf = this.audioStills;
19750
+ const shelf = this.stills;
19677
19751
  if (shelf === null || !this.evaluationActive) return void 0;
19678
19752
  for (const rule of this.rules.listEnabled("immediate")) {
19679
19753
  if (rule.media.attach === "none") continue;
19680
19754
  if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
19681
19755
  const devices = rule.conditions.devices;
19682
19756
  if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
19683
- return shelf.capture(deviceId, atMs);
19757
+ return shelf.capture(deviceId, atMs, "audio");
19684
19758
  }
19685
19759
  }
19686
19760
  /**
@@ -19739,7 +19813,48 @@ var NotificationCenter = class NotificationCenter {
19739
19813
  * Public so a test / a future out-of-band edge source can drive it directly.
19740
19814
  */
19741
19815
  onOccupancyEdge(edge) {
19742
- this.consumeEvent(incomingFromOccupancyEdge(edge));
19816
+ this.consumeEvent(incomingFromOccupancyEdge(edge, this.occupancyStillOwner(edge)));
19817
+ }
19818
+ /**
19819
+ * Start photographing this edge's camera NOW and return the owner id its
19820
+ * subject should name — or `undefined` when nothing would use it.
19821
+ *
19822
+ * **Only an edge that owns NOBODY.** `chooseOccupancyMediaOwner` names a
19823
+ * member track whenever the scope holds one, and that picture is of the
19824
+ * counted object itself; a photograph is strictly the stand-in for not having
19825
+ * one. The case this exists for is "posto libero" (camera 617): the vehicle
19826
+ * whose departure IS the notification has left, so the scope is empty, the
19827
+ * ladder resolves nothing and the delivery logged `no still could be resolved
19828
+ * … owners=[]`. A became-free edge has no owner BY CONSTRUCTION, and a
19829
+ * became-occupied edge can lack one too (every member parked, i.e.
19830
+ * `stationary:`) — so the gate is the ABSENCE itself, not the polarity. That
19831
+ * is the honest predicate: photograph exactly the edges whose media cannot be
19832
+ * resolved, and no others.
19833
+ *
19834
+ * **The capture is not awaited**, for the audio shelf's reason and one of its
19835
+ * own: a freed space is about to be taken by the next car, and a still fetched
19836
+ * after the outbox drained would show it occupied again.
19837
+ *
19838
+ * **The gate is the RULE's media policy AND the engine's own verdict.** The
19839
+ * watcher commits both polarities of every watched key, so a hub with only a
19840
+ * became-occupied rule still produces free edges — photographing those would
19841
+ * be a snapshot no notification can ever carry. `matchesOccupancy` is the same
19842
+ * predicate `evaluateRule` will apply, shared rather than restated. The rule
19843
+ * cache is an in-memory mirror, so this is not a fallible read (D49).
19844
+ */
19845
+ occupancyStillOwner(edge) {
19846
+ const shelf = this.stills;
19847
+ if (shelf === null || !this.evaluationActive) return void 0;
19848
+ if (edge.mediaTrackId !== void 0) return void 0;
19849
+ const subject = occupancySubjectFromEdge(edge);
19850
+ for (const rule of this.rules.listEnabled("device-event")) {
19851
+ if (rule.media.attach === "none") continue;
19852
+ const occ = rule.conditions.occupancy;
19853
+ if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
19854
+ const devices = rule.conditions.devices;
19855
+ if (devices !== void 0 && devices.length > 0 && !devices.includes(edge.deviceId)) continue;
19856
+ return shelf.capture(edge.deviceId, edge.timestamp, "occupancy");
19857
+ }
19743
19858
  }
19744
19859
  /**
19745
19860
  * Feed ONE audio inference frame into the sampling-window matcher.
@@ -20706,7 +20821,7 @@ var NotificationCenter = class NotificationCenter {
20706
20821
  kind,
20707
20822
  ruleId: rule.id,
20708
20823
  rule: rule.name,
20709
- snapshotWired: this.audioStills !== null
20824
+ snapshotWired: this.stills !== null
20710
20825
  }
20711
20826
  });
20712
20827
  }
@@ -20871,6 +20986,7 @@ var NotificationCenter = class NotificationCenter {
20871
20986
  if (subject.kind === "audio-window" || subject.kind === "audio-event") return { outcome: "disabled" };
20872
20987
  const trackId = subject.trackId;
20873
20988
  if (trackId === void 0) return { outcome: "disabled" };
20989
+ if (isStillShelfId(trackId)) return { outcome: "disabled" };
20874
20990
  const member = {
20875
20991
  trackId,
20876
20992
  className: subject.classNames[0] ?? "event",
@@ -24834,6 +24950,188 @@ function clusterByEmbedding(items, threshold = .5) {
24834
24950
  })).toSorted((a, b) => b.size - a.size || b.cohesion - a.cohesion);
24835
24951
  }
24836
24952
  //#endregion
24953
+ //#region src/pipeline-analytics/pipeline/face-matcher.ts
24954
+ /**
24955
+ * Count enrolled samples per identity for probes of a matching model+dimension.
24956
+ * Only identities meeting `minIdentitySamples` are eligible auto-match targets.
24957
+ */
24958
+ function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples) {
24959
+ const counts = /* @__PURE__ */ new Map();
24960
+ for (const s of gallery) {
24961
+ if (s.modelId !== probeModelId) continue;
24962
+ if (s.embedding.length !== probeDim) continue;
24963
+ counts.set(s.identityId, (counts.get(s.identityId) ?? 0) + 1);
24964
+ }
24965
+ const eligible = /* @__PURE__ */ new Set();
24966
+ for (const [id, count] of counts) if (count >= minIdentitySamples) eligible.add(id);
24967
+ return eligible;
24968
+ }
24969
+ /**
24970
+ * Match a probe embedding against the gallery. Only samples with the same
24971
+ * `modelId` AND the same dimension are compared (model-version safety). Returns
24972
+ * the best identity when its score ≥ threshold and it beats the best OTHER
24973
+ * identity by ≥ margin; otherwise null.
24974
+ */
24975
+ function matchEmbedding(probe, gallery, opts) {
24976
+ const probeVec = new Float32Array(probe.embedding);
24977
+ const eligible = eligibleIdentities(gallery, probe.modelId, probe.embedding.length, opts.minIdentitySamples ?? 1);
24978
+ const bestByIdentity = /* @__PURE__ */ new Map();
24979
+ for (const s of gallery) {
24980
+ if (s.modelId !== probe.modelId) continue;
24981
+ if (s.embedding.length !== probe.embedding.length) continue;
24982
+ if (!eligible.has(s.identityId)) continue;
24983
+ const score = cosineSimilarity$1(probeVec, new Float32Array(s.embedding));
24984
+ const prev = bestByIdentity.get(s.identityId);
24985
+ if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
24986
+ }
24987
+ if (bestByIdentity.size === 0) return null;
24988
+ let bestId = null;
24989
+ let bestScore = -Infinity;
24990
+ let secondScore = -Infinity;
24991
+ for (const [id, score] of bestByIdentity) if (score > bestScore) {
24992
+ secondScore = bestScore;
24993
+ bestScore = score;
24994
+ bestId = id;
24995
+ } else if (score > secondScore) secondScore = score;
24996
+ if (bestId === null || bestScore < opts.threshold) return null;
24997
+ if (secondScore > -Infinity && bestScore - secondScore < opts.margin) return null;
24998
+ return {
24999
+ identityId: bestId,
25000
+ score: bestScore
25001
+ };
25002
+ }
25003
+ /**
25004
+ * Assign at most one identity per track AND at most one track per identity for
25005
+ * a single frame. Greedy by score: compute every candidate's full ranked match
25006
+ * list, then repeatedly take the globally-highest (track, identity) pair whose
25007
+ * track and identity are both still free, until none qualify. A losing track
25008
+ * falls back to its next-best free identity ≥ threshold. Ties resolve by input order.
25009
+ */
25010
+ /**
25011
+ * Enrollment bound: the maximum cosine an embedding may score against a sample
25012
+ * of a DIFFERENT identity and still be enrolled. Genuine ArcFace cross-person
25013
+ * cosines sit under ~0.4; the low-information attractor (IR, blur, backs of
25014
+ * heads) sits at 0.95+. Measured 2026-08-18: 22 of 50 live samples were
25015
+ * enrolled from that attractor, with inter-identity cosines up to 0.988 — each
25016
+ * one a magnet that "recognises" every degraded probe as its owner.
25017
+ */
25018
+ var ENROLLMENT_MAX_FOREIGN_COSINE = .9;
25019
+ /**
25020
+ * Gate an embedding BEFORE it becomes a gallery sample: condemned when its
25021
+ * best cosine against any sample of ANOTHER identity (same model + dimension)
25022
+ * reaches {@link ENROLLMENT_MAX_FOREIGN_COSINE}. Null = safe to enroll.
25023
+ * Closeness to the TARGET identity's own samples is expected and never
25024
+ * condemns.
25025
+ */
25026
+ function enrollmentPromiscuity(embedding, modelId, targetIdentityId, gallery, bound = ENROLLMENT_MAX_FOREIGN_COSINE) {
25027
+ const probeVec = new Float32Array(embedding);
25028
+ let worst = null;
25029
+ for (const s of gallery) {
25030
+ if (s.identityId === targetIdentityId) continue;
25031
+ if (s.modelId !== modelId) continue;
25032
+ if (s.embedding.length !== embedding.length) continue;
25033
+ const cosine = cosineSimilarity$1(probeVec, new Float32Array(s.embedding));
25034
+ if (cosine >= bound && (worst === null || cosine > worst.cosine)) worst = {
25035
+ identityId: s.identityId,
25036
+ cosine
25037
+ };
25038
+ }
25039
+ return worst;
25040
+ }
25041
+ function assignUniquePerFrame(candidates, gallery, opts) {
25042
+ const pairs = [];
25043
+ candidates.forEach((c, trackIdx) => {
25044
+ const probeVec = new Float32Array(c.embedding);
25045
+ const eligible = eligibleIdentities(gallery, c.modelId, c.embedding.length, opts.minIdentitySamples ?? 1);
25046
+ const bestByIdentity = /* @__PURE__ */ new Map();
25047
+ for (const s of gallery) {
25048
+ if (s.modelId !== c.modelId) continue;
25049
+ if (s.embedding.length !== c.embedding.length) continue;
25050
+ if (!eligible.has(s.identityId)) continue;
25051
+ const score = cosineSimilarity$1(probeVec, new Float32Array(s.embedding));
25052
+ const prev = bestByIdentity.get(s.identityId);
25053
+ if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
25054
+ }
25055
+ let bestId = null;
25056
+ let bestScore = -1;
25057
+ let secondScore = -1;
25058
+ for (const [identityId, score] of bestByIdentity) if (score > bestScore) {
25059
+ if (bestId !== null) secondScore = bestScore;
25060
+ bestId = identityId;
25061
+ bestScore = score;
25062
+ } else if (score > secondScore) secondScore = score;
25063
+ const ambiguous = secondScore >= 0 && bestScore - secondScore < opts.margin;
25064
+ if (bestId !== null && !ambiguous) {
25065
+ for (const [identityId, score] of bestByIdentity) if (score >= opts.threshold) pairs.push({
25066
+ trackIdx,
25067
+ identityId,
25068
+ score
25069
+ });
25070
+ }
25071
+ });
25072
+ pairs.sort((a, b) => b.score - a.score);
25073
+ const takenTracks = /* @__PURE__ */ new Set();
25074
+ const takenIdentities = /* @__PURE__ */ new Set();
25075
+ const result = /* @__PURE__ */ new Map();
25076
+ for (const p of pairs) {
25077
+ if (takenTracks.has(p.trackIdx) || takenIdentities.has(p.identityId)) continue;
25078
+ takenTracks.add(p.trackIdx);
25079
+ takenIdentities.add(p.identityId);
25080
+ result.set(candidates[p.trackIdx].trackId, {
25081
+ identityId: p.identityId,
25082
+ score: p.score
25083
+ });
25084
+ }
25085
+ return result;
25086
+ }
25087
+ var EMPTY_AGGREGATE = Object.freeze({
25088
+ assignedIdentityId: null,
25089
+ confirmations: Object.freeze({})
25090
+ });
25091
+ /**
25092
+ * Fold one frame's match into a track's aggregate. A null match leaves state
25093
+ * untouched. An identity is assigned once its cumulative confirmation count
25094
+ * reaches `confirmFrames`; once assigned, a different identity must independently
25095
+ * reach `confirmFrames` to switch. `changed` is true only on a (re)assignment to
25096
+ * a NEW identity, so callers fire the label update exactly once per transition.
25097
+ *
25098
+ * On each (re)assignment the confirmations record is reset to contain only the
25099
+ * newly-assigned identity's count, so any challenger must independently re-reach
25100
+ * `confirmFrames` from scratch after any assignment.
25101
+ */
25102
+ function updateTrackAggregate(prev, match, opts) {
25103
+ if (match === null) return {
25104
+ state: prev,
25105
+ changed: false
25106
+ };
25107
+ const count = (prev.confirmations[match.identityId] ?? 0) + 1;
25108
+ const confirmations = {
25109
+ ...prev.confirmations,
25110
+ [match.identityId]: count
25111
+ };
25112
+ if (count < opts.confirmFrames) return {
25113
+ state: {
25114
+ ...prev,
25115
+ confirmations
25116
+ },
25117
+ changed: false
25118
+ };
25119
+ if (prev.assignedIdentityId === match.identityId) return {
25120
+ state: {
25121
+ ...prev,
25122
+ confirmations
25123
+ },
25124
+ changed: false
25125
+ };
25126
+ return {
25127
+ state: {
25128
+ assignedIdentityId: match.identityId,
25129
+ confirmations: { [match.identityId]: count }
25130
+ },
25131
+ changed: true
25132
+ };
25133
+ }
25134
+ //#endregion
24837
25135
  //#region src/pipeline-analytics/face-gallery-provider.ts
24838
25136
  /** Attribution of a face identity NAMED BY THE OPERATOR in the gallery. */
24839
25137
  var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
@@ -25080,6 +25378,19 @@ var FaceGalleryProvider = class {
25080
25378
  }
25081
25379
  const currentFace = await this.faceStore.get(faceId);
25082
25380
  if (!currentFace) throw new Error(`FaceGalleryProvider.assignFace: face disappeared after pre-cleanup: ${faceId}`);
25381
+ const verdict = enrollmentPromiscuity(currentFace.embedding, MODEL_ID, identityId, await this.identityStore.loadGallery());
25382
+ if (verdict !== null) {
25383
+ this.logger.warn("assignFace refused: embedding too close to a FOREIGN identity sample", {
25384
+ tags: { deviceId: currentFace.deviceId },
25385
+ meta: {
25386
+ faceId,
25387
+ identityId,
25388
+ foreignIdentityId: verdict.identityId,
25389
+ cosine: verdict.cosine
25390
+ }
25391
+ });
25392
+ 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`);
25393
+ }
25083
25394
  let newKey;
25084
25395
  if (currentFace.mediaKey) {
25085
25396
  newKey = await this.mediaStore.reown({
@@ -33492,146 +33803,6 @@ async function resolveDefaultEventMedia(deps) {
33492
33803
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
33493
33804
  }
33494
33805
  //#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
33806
  //#region src/pipeline-analytics/face-recognizer.ts
33636
33807
  /** Attribution of an identity resolved by the in-process ArcFace matcher. */
33637
33808
  var FACE_RECOGNIZER_STEP_ID = "face-recognizer";
@@ -52336,6 +52507,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
52336
52507
  },
52337
52508
  renderEventMedia: (req) => this.renderEventMedia(req),
52338
52509
  publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
52510
+ viewerUrlBase: async () => pickArtifactBaseUrl(await this.ncEndpoints()),
52339
52511
  indexArtifact: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? {
52340
52512
  url: null,
52341
52513
  artifactId: null