@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.
@@ -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-BdojoP7i.js");
5
+ const require_dist = require("../dist-BQsMSX2A.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");
@@ -5562,24 +5562,39 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5562
5562
  }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
5563
5563
  }
5564
5564
  //#endregion
5565
- //#region src/notification-center/audio-still.ts
5565
+ //#region src/notification-center/still-shelf.ts
5566
5566
  /**
5567
- * The audio still shelf — the PHOTOGRAPH a sound rule's notification carries.
5567
+ * The still shelf — the PHOTOGRAPH a notification carries when nothing it can
5568
+ * name owns a frame.
5568
5569
  *
5569
- * ## Why a sound needs one at all
5570
+ * ## Why a trigger needs one at all
5570
5571
  *
5571
- * The attachment ladder resolves media by OWNER, and every other trigger has
5572
- * one: an object or package event owns its crops, a closed track owns its best
5573
- * shot, a doorbell press owns the marker track the same press projected
5572
+ * The attachment ladder resolves media by OWNER, and most triggers have one: an
5573
+ * object or package event owns its crops, a closed track owns its best shot, a
5574
+ * doorbell press owns the marker track the same press projected
5574
5575
  * (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
5575
- * counted (`chooseOccupancyMediaOwner`). An audio match owns nothing. Nothing
5576
- * was boxed, nothing was tracked, and — deliberately — nothing is persisted at
5577
- * all: a confirmed window is a claim about sound that has already stopped, and
5578
- * `event-intake.ts` states why replaying it later would be wrong.
5576
+ * counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
5577
+ * same reason in both cases — the subject is an ABSENCE:
5578
+ *
5579
+ * - an **audio** match: nothing was boxed, nothing was tracked, and —
5580
+ * deliberately — nothing is persisted at all. A confirmed window is a claim
5581
+ * about sound that has already stopped, and `event-intake.ts` states why
5582
+ * replaying it later would be wrong.
5583
+ * - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
5584
+ * whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
5585
+ * nobody and the ladder logs `no still could be resolved … owners=[]`.
5579
5586
  *
5580
5587
  * So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
5581
- * of the match. Not of the sound — of what the camera could see while it was
5582
- * heard.
5588
+ * of the trigger. Not of the sound, not of the object that left — of what the
5589
+ * camera can see now that it happened. For a freed parking space that is
5590
+ * exactly the answer the operator wants: the space, empty.
5591
+ *
5592
+ * **One shelf, not two.** The mechanism is identical down to the reuse window,
5593
+ * and the only thing that differs between the two triggers is the GATE deciding
5594
+ * that a photograph is owed at all — which belongs at the trigger site, where
5595
+ * the rules are, and not here. A twin module would be a second derivation of
5596
+ * one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
5597
+ * can say which absence they are about.
5583
5598
  *
5584
5599
  * ## Three properties, and each one is a decision
5585
5600
  *
@@ -5587,27 +5602,30 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5587
5602
  * TTL that covers the outbox's whole retry horizon — and nowhere else. The
5588
5603
  * alternative was the doorbell's: materialise a synthetic marker track through
5589
5604
  * `SyntheticTrackMaterializer` and let the notification name it. That would put
5590
- * a durable Track on the camera's timeline for every confirmed window, feeding
5591
- * the digest's `listTracks`, retention, and the audio-marker feature's own
5592
- * operator ceilings (`audio-marker-projector.ts` exists precisely to bound how
5593
- * many audio markers a camera may emit). A notification must not manufacture
5594
- * timeline history as a side effect of wanting a picture.
5595
- *
5596
- * **The capture STARTS immediately and is never awaited.** A sound is transient
5597
- * — a scream is over before a snapshot round-trip completes — so the fetch is
5598
- * kicked off at the confirmation, before the rule evaluation runs, and the
5599
- * owner id is minted synchronously so the outbox row can name it. The bytes
5600
- * land while the row waits in the queue, and the dispatcher's existing bounded
5601
- * still-wait (or the pause its own footage render already costs) picks them up.
5602
- * A camera that never answers costs the picture and never the notification.
5603
- *
5604
- * **Two confirmations seconds apart share ONE capture.** A barking dog confirms
5605
+ * a durable Track on the camera's timeline for every confirmed window and every
5606
+ * emptied zone, feeding the digest's `listTracks`, retention, and the audio-
5607
+ * marker feature's own operator ceilings (`audio-marker-projector.ts` exists
5608
+ * precisely to bound how many audio markers a camera may emit). A notification
5609
+ * must not manufacture timeline history as a side effect of wanting a picture.
5610
+ *
5611
+ * **The capture STARTS immediately and is never awaited.** The subject is
5612
+ * transient — a scream is over before a snapshot round-trip completes, and a
5613
+ * freed space is about to be taken by the next car — so the fetch is kicked off
5614
+ * at the trigger, before the rule evaluation runs, and the owner id is minted
5615
+ * synchronously so the outbox row can name it. The bytes land while the row
5616
+ * waits in the queue, and the dispatcher's existing bounded still-wait (or the
5617
+ * pause its own footage render already costs) picks them up. A camera that
5618
+ * never answers costs the picture and never the notification.
5619
+ *
5620
+ * **Two triggers seconds apart share ONE capture.** A barking dog confirms
5605
5621
  * repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
5606
5622
  * rule's cooldown is its only brake, and the cooldown is applied AFTER this.
5607
5623
  * Without a reuse window this would photograph a camera at whatever rate the
5608
- * sound happens to occur. Each confirmation still gets its OWN owner id, so two
5624
+ * sound happens to occur. Each trigger still gets its OWN owner id, so two
5609
5625
  * outbox rows are never mistaken for one subject; they merely point at the same
5610
- * frame, which is the truth — the scene did not change in ten seconds.
5626
+ * frame, which is the truth — the scene did not change in ten seconds. The
5627
+ * window is per CAMERA and trigger-agnostic for the same reason: a sound and an
5628
+ * emptied zone ten seconds apart are two claims about one scene.
5611
5629
  *
5612
5630
  * Nothing here is silent: a capture that lands and a camera that refuses each
5613
5631
  * emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
@@ -5615,13 +5633,13 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5615
5633
  */
5616
5634
  /**
5617
5635
  * The owner-id namespace. It is what routes a lookup here instead of to the
5618
- * media store, and it is the reason the dispatcher needs no audio branch —
5619
- * `getMediaForOwner` answers for both under one signature.
5636
+ * media store, and it is the reason the dispatcher needs no audio or occupancy
5637
+ * branch — `getMediaForOwner` answers for all of them under one signature.
5620
5638
  */
5621
- var NC_AUDIO_STILL_PREFIX = "nc-audio-still:";
5639
+ var NC_STILL_SHELF_PREFIX = "nc-still:";
5622
5640
  /** True for an owner id this shelf minted. */
5623
- function isAudioStillId(id) {
5624
- return id.startsWith(NC_AUDIO_STILL_PREFIX);
5641
+ function isStillShelfId(id) {
5642
+ return id.startsWith(NC_STILL_SHELF_PREFIX);
5625
5643
  }
5626
5644
  /**
5627
5645
  * How long the bytes are held.
@@ -5631,16 +5649,16 @@ function isAudioStillId(id) {
5631
5649
  * ships text-only, which is the correct degradation for a photograph of a scene
5632
5650
  * that is a quarter of an hour stale anyway.
5633
5651
  */
5634
- var NC_AUDIO_STILL_TTL_MS = 15 * 6e4;
5652
+ var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
5635
5653
  /** A hanging snapshot cap must not pin a capture slot forever. */
5636
5654
  var SNAPSHOT_TIMEOUT_MS = 8e3;
5637
5655
  /**
5638
- * Hard bound on held captures. Reached only if every camera on the hub confirms
5639
- * an audio rule inside one TTL; the oldest is dropped first, which costs a
5656
+ * Hard bound on held captures. Reached only if every camera on the hub triggers
5657
+ * one inside a single TTL; the oldest is dropped first, which costs a
5640
5658
  * fifteen-minute-old picture nobody is waiting for.
5641
5659
  */
5642
5660
  var MAX_CAPTURES = 64;
5643
- var NcAudioStillShelf = class {
5661
+ var NcStillShelf = class {
5644
5662
  deps;
5645
5663
  /** captureId → the photograph. */
5646
5664
  captures = /* @__PURE__ */ new Map();
@@ -5652,14 +5670,14 @@ var NcAudioStillShelf = class {
5652
5670
  this.deps = deps;
5653
5671
  }
5654
5672
  /**
5655
- * Photograph `deviceId` for a match at `atMs`, and return the owner id the
5656
- * subject should name. SYNCHRONOUS by contract: the caller is on the audio
5657
- * confirmation path and the outbox row is built from what this returns.
5673
+ * Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
5674
+ * subject should name. SYNCHRONOUS by contract: the caller is on the trigger
5675
+ * path and the outbox row is built from what this returns.
5658
5676
  */
5659
- capture(deviceId, atMs) {
5677
+ capture(deviceId, atMs, trigger) {
5660
5678
  const now = this.deps.now();
5661
5679
  this.prune(now);
5662
- const ownerId = `${NC_AUDIO_STILL_PREFIX}${(0, node_crypto.randomUUID)()}`;
5680
+ const ownerId = `${NC_STILL_SHELF_PREFIX}${(0, node_crypto.randomUUID)()}`;
5663
5681
  const recent = this.newest.get(deviceId);
5664
5682
  if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
5665
5683
  this.owners.set(ownerId, recent.captureId);
@@ -5668,8 +5686,9 @@ var NcAudioStillShelf = class {
5668
5686
  const captureId = (0, node_crypto.randomUUID)();
5669
5687
  this.captures.set(captureId, {
5670
5688
  deviceId,
5689
+ trigger,
5671
5690
  startedAt: now,
5672
- expiresAt: now + NC_AUDIO_STILL_TTL_MS,
5691
+ expiresAt: now + NC_STILL_SHELF_TTL_MS,
5673
5692
  files: []
5674
5693
  });
5675
5694
  this.newest.set(deviceId, {
@@ -5677,7 +5696,7 @@ var NcAudioStillShelf = class {
5677
5696
  at: now
5678
5697
  });
5679
5698
  this.owners.set(ownerId, captureId);
5680
- this.fetch(captureId, deviceId, atMs);
5699
+ this.fetch(captureId, deviceId, atMs, trigger);
5681
5700
  return ownerId;
5682
5701
  }
5683
5702
  /**
@@ -5713,11 +5732,11 @@ var NcAudioStillShelf = class {
5713
5732
  this.owners.clear();
5714
5733
  this.newest.clear();
5715
5734
  }
5716
- async fetch(captureId, deviceId, atMs) {
5735
+ async fetch(captureId, deviceId, atMs, trigger) {
5717
5736
  try {
5718
5737
  const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
5719
5738
  if (shot === null || shot.base64.length === 0) {
5720
- this.reportMiss(deviceId, "the camera returned no snapshot");
5739
+ this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
5721
5740
  return;
5722
5741
  }
5723
5742
  const raw = Buffer.from(shot.base64, "base64");
@@ -5729,17 +5748,18 @@ var NcAudioStillShelf = class {
5729
5748
  if (held === void 0) return;
5730
5749
  this.captures.set(captureId, {
5731
5750
  ...held,
5732
- files: audioStillMedia(data, atMs)
5751
+ files: stillShelfMedia(data, atMs)
5733
5752
  });
5734
- this.deps.logger.info("audio still captured", {
5753
+ this.deps.logger.info(`${trigger} still captured`, {
5735
5754
  tags: { deviceId },
5736
5755
  meta: {
5756
+ trigger,
5737
5757
  bytes: data.byteLength,
5738
5758
  tookMs: this.deps.now() - held.startedAt
5739
5759
  }
5740
5760
  });
5741
5761
  } catch (err) {
5742
- this.reportMiss(deviceId, err instanceof Error ? err.message : String(err));
5762
+ this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
5743
5763
  }
5744
5764
  }
5745
5765
  /**
@@ -5747,10 +5767,13 @@ var NcAudioStillShelf = class {
5747
5767
  * on a notification they DID receive, so it is a warn and it carries the
5748
5768
  * camera — the only key the question is ever asked with.
5749
5769
  */
5750
- reportMiss(deviceId, reason) {
5751
- this.deps.logger.warn("the camera did not answer the audio still — this notification ships text-only", {
5770
+ reportMiss(deviceId, trigger, reason) {
5771
+ this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
5752
5772
  tags: { deviceId },
5753
- meta: { reason }
5773
+ meta: {
5774
+ trigger,
5775
+ reason
5776
+ }
5754
5777
  });
5755
5778
  }
5756
5779
  };
@@ -5765,10 +5788,10 @@ var NcAudioStillShelf = class {
5765
5788
  * so. Inventing a centre crop would answer a different question from the one
5766
5789
  * the operator asked.
5767
5790
  */
5768
- function audioStillMedia(data, timestamp) {
5791
+ function stillShelfMedia(data, timestamp) {
5769
5792
  const base64 = data.toString("base64");
5770
5793
  const file = (kind) => ({
5771
- key: `${NC_AUDIO_STILL_PREFIX}${kind}`,
5794
+ key: `${NC_STILL_SHELF_PREFIX}${kind}`,
5772
5795
  kind,
5773
5796
  base64,
5774
5797
  sizeBytes: data.byteLength,
@@ -6405,6 +6428,16 @@ var NcDeviceStateCache = class {
6405
6428
  }
6406
6429
  };
6407
6430
  //#endregion
6431
+ //#region src/notification-center/default-tap-url.ts
6432
+ /** The viewer's hosted base path under the hub. */
6433
+ var VIEWER_BASE_PATH = "/viewer/camstack";
6434
+ function defaultTapUrl(input) {
6435
+ if (input.systemEvent === true) return null;
6436
+ if (input.baseUrl === null || input.baseUrl.length === 0) return null;
6437
+ if (!Number.isInteger(input.deviceId) || input.deviceId <= 0) return null;
6438
+ return `${input.baseUrl.replace(/\/+$/, "")}${VIEWER_BASE_PATH}/camera/${String(input.deviceId)}?at=${String(input.timestampMs)}`;
6439
+ }
6440
+ //#endregion
6408
6441
  //#region src/shared/frame/box-drawer.ts
6409
6442
  var DEFAULT_COLOR = require_dist.DEFAULT_EVENT_COLOR;
6410
6443
  var DEFAULT_QUALITY = 80;
@@ -7424,7 +7457,7 @@ function subjectFromSensorEvent(ev, markerTrackId) {
7424
7457
  * (see the {@link evaluateRule} audio gate + the matchers below).
7425
7458
  *
7426
7459
  * `stillOwnerId` is the audio still shelf's owner id — the photograph of the
7427
- * camera taken at the match (`audio-still.ts`). It rides `trackId` because that
7460
+ * camera taken at the match (`still-shelf.ts`). It rides `trackId` because that
7428
7461
  * is the field the attachment ladder reads, exactly as a doorbell press rides
7429
7462
  * its marker track's id; nothing else about the subject is track-scoped, and
7430
7463
  * `buildEntries` keys an audio row on its record id, never on this. Absent when
@@ -7516,10 +7549,18 @@ function audioSubjectFor(hit, spec) {
7516
7549
  * chosen at commit (`chooseOccupancyMediaOwner`) — rides on `trackId`, and that
7517
7550
  * is the whole of what gives an occupancy notification a picture: the ordinary
7518
7551
  * media-owner ladder resolves it, exactly as it resolves a doorbell's marker
7519
- * track. Absent when the scope held no member (a became-free edge) ⇒ text-only,
7520
- * by design.
7521
- */
7522
- function subjectFromOccupancyEvent(edge) {
7552
+ * track.
7553
+ *
7554
+ * `stillOwnerId` is the SECOND source of that picture and the only one an
7555
+ * emptied scope can have: with no member left to name — "posto libero", where
7556
+ * the object whose departure is the news has gone — the trigger site photographs
7557
+ * the camera and holds the bytes on the still shelf (`still-shelf.ts`), exactly
7558
+ * as an audio match does. The member track always WINS: it is a picture of the
7559
+ * counted object, and a photograph is only ever the stand-in for not having one.
7560
+ * Neither present (no snapshot cap on this node) ⇒ text-only, as before.
7561
+ */
7562
+ function subjectFromOccupancyEvent(edge, stillOwnerId) {
7563
+ const mediaOwner = edge.mediaTrackId ?? stillOwnerId;
7523
7564
  return {
7524
7565
  kind: "occupancy-event",
7525
7566
  recordId: `occ:${edge.deviceId}:${edge.zoneId ?? "@frame"}:${edge.className ?? "@all"}:t${edge.threshold}:${edge.occupied ? "occ" : "free"}:${edge.timestamp}`,
@@ -7527,17 +7568,28 @@ function subjectFromOccupancyEvent(edge) {
7527
7568
  timestamp: edge.timestamp,
7528
7569
  classNames: [edge.className ?? "occupancy"],
7529
7570
  zones: [],
7530
- ...edge.mediaTrackId !== void 0 ? { trackId: edge.mediaTrackId } : {},
7571
+ ...mediaOwner !== void 0 ? { trackId: mediaOwner } : {},
7531
7572
  source: "pipeline",
7532
- occupancy: {
7533
- ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
7534
- ...edge.zoneName !== void 0 ? { zoneName: edge.zoneName } : {},
7535
- ...edge.className !== void 0 ? { className: edge.className } : {},
7536
- count: edge.count,
7537
- previousCount: edge.previousCount,
7538
- occupied: edge.occupied,
7539
- threshold: edge.threshold
7540
- }
7573
+ occupancy: occupancySubjectFromEdge(edge)
7574
+ };
7575
+ }
7576
+ /**
7577
+ * The scope + level half of an occupancy subject, ALONE.
7578
+ *
7579
+ * Split out for one reason: the still-shelf gate must ask `matchesOccupancy`
7580
+ * whether a rule would fire on an edge BEFORE the subject that would carry the
7581
+ * answer exists (the owner id it mints is an input to that subject). Sharing
7582
+ * this keeps the gate reading the same numbers the verdict will.
7583
+ */
7584
+ function occupancySubjectFromEdge(edge) {
7585
+ return {
7586
+ ...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
7587
+ ...edge.zoneName !== void 0 ? { zoneName: edge.zoneName } : {},
7588
+ ...edge.className !== void 0 ? { className: edge.className } : {},
7589
+ count: edge.count,
7590
+ previousCount: edge.previousCount,
7591
+ occupied: edge.occupied,
7592
+ threshold: edge.threshold
7541
7593
  };
7542
7594
  }
7543
7595
  /** Build the subject for a `package-event` evaluation (a persisted `package`
@@ -7664,6 +7716,13 @@ function matchesAudio(cond, s) {
7664
7716
  }
7665
7717
  return false;
7666
7718
  }
7719
+ /**
7720
+ * Exported for ONE caller: the still-shelf gate in the centre, which must know
7721
+ * — before the outbox row exists — whether any enabled rule would fire on THIS
7722
+ * edge and wants a picture for it. Sharing the predicate is what keeps that gate
7723
+ * from becoming a second, drifting opinion about which edge belongs to which
7724
+ * rule. It is pure and reads nothing.
7725
+ */
7667
7726
  function matchesOccupancy(occ, s) {
7668
7727
  if ((occ.zoneId ?? void 0) !== (s.zoneId ?? void 0)) return false;
7669
7728
  if ((occ.className ?? void 0) !== (s.className ?? void 0)) return false;
@@ -9262,12 +9321,19 @@ var NcDispatcher = class {
9262
9321
  }) : rendered;
9263
9322
  const attachments = [...await this.withArtifactUrls(entry, await this.resolveAttachments(entry)), ...frozenArtifactAttachments(entry)];
9264
9323
  const params = pickParams(entry.payload.params);
9324
+ const tapUrl = params.clickUrl === void 0 && this.deps.viewerUrlBase !== void 0 ? defaultTapUrl({
9325
+ deviceId: systemEvent !== void 0 ? 0 : subject.deviceId,
9326
+ timestampMs: subject.timestamp,
9327
+ baseUrl: await this.deps.viewerUrlBase().catch(() => null),
9328
+ systemEvent: systemEvent !== void 0
9329
+ }) : null;
9265
9330
  const actions = await this.mintActions(entry, target);
9266
9331
  return {
9267
9332
  body,
9268
9333
  title,
9269
9334
  priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
9270
9335
  ...params,
9336
+ ...tapUrl !== null ? { clickUrl: tapUrl } : {},
9271
9337
  tag: entry.payload.group?.key ?? entry.ruleId,
9272
9338
  ...systemEvent !== void 0 ? systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {} : { deviceId: subject.deviceId },
9273
9339
  ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
@@ -9864,7 +9930,7 @@ function incomingFromSensorEvent(event, markerTrackId) {
9864
9930
  * Audio-event persist (classified episode).
9865
9931
  *
9866
9932
  * `stillOwnerId` is the audio still shelf's owner id for the photograph taken
9867
- * at this episode (`audio-still.ts`). Absent when no rule that could fire asked
9933
+ * at this episode (`still-shelf.ts`). Absent when no rule that could fire asked
9868
9934
  * for a picture — the same shape `incomingFromSensorEvent` carries its marker.
9869
9935
  */
9870
9936
  function incomingFromAudioEvent(event, stillOwnerId) {
@@ -9945,10 +10011,16 @@ function audioHitMeta(hit) {
9945
10011
  samples: window.samples
9946
10012
  };
9947
10013
  }
9948
- /** A committed ZoneAnalytics occupancy edge. */
9949
- function incomingFromOccupancyEdge(edge) {
10014
+ /**
10015
+ * A committed ZoneAnalytics occupancy edge.
10016
+ *
10017
+ * `stillOwnerId` — see {@link subjectFromOccupancyEvent}. An edge whose scope is
10018
+ * empty owns no member track, so the only picture it can carry is a photograph
10019
+ * of its camera taken at the edge, held on the still shelf under this id.
10020
+ */
10021
+ function incomingFromOccupancyEdge(edge, stillOwnerId) {
9950
10022
  return {
9951
- subject: subjectFromOccupancyEvent(edge),
10023
+ subject: subjectFromOccupancyEvent(edge, stillOwnerId),
9952
10024
  kind: "occupancy-event",
9953
10025
  origin: "pipeline",
9954
10026
  log: () => ({
@@ -16077,7 +16149,7 @@ function isSyntheticId(id) {
16077
16149
  * - and, since 2026-08-15, a `trackId` for both AUDIO kinds: a sound owns no
16078
16150
  * frame, so production photographs the camera at the match and holds the
16079
16151
  * bytes on the still shelf under an owner id the subject names
16080
- * (`audio-still.ts`). The tester's RAM-held frame stands in for exactly that
16152
+ * (`still-shelf.ts`). The tester's RAM-held frame stands in for exactly that
16081
16153
  * photograph, and — unlike every other kind here — it is taken the same way
16082
16154
  * production takes it, at the same moment, from the same cap.
16083
16155
  *
@@ -18744,11 +18816,12 @@ var NotificationCenter = class NotificationCenter {
18744
18816
  */
18745
18817
  syntheticMedia = /* @__PURE__ */ new Map();
18746
18818
  /**
18747
- * The photograph a SOUND rule's notification carries — see `audio-still.ts`.
18748
- * Null when this wiring has no snapshot cap, which leaves every audio
18749
- * notification text-only rather than broken.
18819
+ * The photograph a notification carries when the subject it is about is an
18820
+ * ABSENCE — a sound that owns no frame, an emptied occupancy scope that owns
18821
+ * no member. See `still-shelf.ts`. Null when this wiring has no snapshot cap,
18822
+ * which leaves those notifications text-only rather than broken.
18750
18823
  */
18751
- audioStills;
18824
+ stills;
18752
18825
  /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
18753
18826
  lastNoMatchReportAt = /* @__PURE__ */ new Map();
18754
18827
  noMatchSuppressed = /* @__PURE__ */ new Map();
@@ -19045,9 +19118,9 @@ var NotificationCenter = class NotificationCenter {
19045
19118
  this.logger = deps.logger;
19046
19119
  this.now = deps.now ?? (() => Date.now());
19047
19120
  const getSnapshot = deps.getSnapshot;
19048
- this.audioStills = getSnapshot !== void 0 ? new NcAudioStillShelf({
19121
+ this.stills = getSnapshot !== void 0 ? new NcStillShelf({
19049
19122
  getSnapshot,
19050
- logger: this.logger.child("audio-still"),
19123
+ logger: this.logger.child("still-shelf"),
19051
19124
  now: this.now
19052
19125
  }) : null;
19053
19126
  this.rules = new NcRuleStore({
@@ -19137,8 +19210,8 @@ var NotificationCenter = class NotificationCenter {
19137
19210
  getMediaForOwner: async (ownerKind, ownerId) => {
19138
19211
  const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
19139
19212
  if (held !== void 0) return held.files;
19140
- const audio = isAudioStillId(ownerId) ? this.audioStills?.get(ownerId) : void 0;
19141
- if (audio !== void 0) return audio;
19213
+ const shelved = isStillShelfId(ownerId) ? this.stills?.get(ownerId) : void 0;
19214
+ if (shelved !== void 0) return shelved;
19142
19215
  return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
19143
19216
  },
19144
19217
  ...deps.now !== void 0 ? { now: deps.now } : {},
@@ -19148,7 +19221,8 @@ var NotificationCenter = class NotificationCenter {
19148
19221
  confirmGate: (input) => confirmGate.judge(input),
19149
19222
  confirmStats: () => confirmGate.stats()
19150
19223
  } : {},
19151
- releaseCooldown: (input) => this.releaseCooldown(input)
19224
+ releaseCooldown: (input) => this.releaseCooldown(input),
19225
+ ...deps.dispatcher.viewerUrlBase !== void 0 ? { viewerUrlBase: deps.dispatcher.viewerUrlBase } : {}
19152
19226
  });
19153
19227
  this.outbox = new NcOutbox({
19154
19228
  store: deps.store,
@@ -19343,7 +19417,7 @@ var NotificationCenter = class NotificationCenter {
19343
19417
  }
19344
19418
  this.timelapseScheduler?.stop();
19345
19419
  this.summaryProducer?.stop();
19346
- this.audioStills?.clear();
19420
+ this.stills?.clear();
19347
19421
  this.addonUpdates.dispose();
19348
19422
  this.livenessHoldoff.dispose();
19349
19423
  this.evaluationActive = false;
@@ -19695,14 +19769,14 @@ var NotificationCenter = class NotificationCenter {
19695
19769
  * direction that DESTROYS the media.
19696
19770
  */
19697
19771
  audioStillOwner(deviceId, atMs, mode) {
19698
- const shelf = this.audioStills;
19772
+ const shelf = this.stills;
19699
19773
  if (shelf === null || !this.evaluationActive) return void 0;
19700
19774
  for (const rule of this.rules.listEnabled("immediate")) {
19701
19775
  if (rule.media.attach === "none") continue;
19702
19776
  if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
19703
19777
  const devices = rule.conditions.devices;
19704
19778
  if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
19705
- return shelf.capture(deviceId, atMs);
19779
+ return shelf.capture(deviceId, atMs, "audio");
19706
19780
  }
19707
19781
  }
19708
19782
  /**
@@ -19761,7 +19835,48 @@ var NotificationCenter = class NotificationCenter {
19761
19835
  * Public so a test / a future out-of-band edge source can drive it directly.
19762
19836
  */
19763
19837
  onOccupancyEdge(edge) {
19764
- this.consumeEvent(incomingFromOccupancyEdge(edge));
19838
+ this.consumeEvent(incomingFromOccupancyEdge(edge, this.occupancyStillOwner(edge)));
19839
+ }
19840
+ /**
19841
+ * Start photographing this edge's camera NOW and return the owner id its
19842
+ * subject should name — or `undefined` when nothing would use it.
19843
+ *
19844
+ * **Only an edge that owns NOBODY.** `chooseOccupancyMediaOwner` names a
19845
+ * member track whenever the scope holds one, and that picture is of the
19846
+ * counted object itself; a photograph is strictly the stand-in for not having
19847
+ * one. The case this exists for is "posto libero" (camera 617): the vehicle
19848
+ * whose departure IS the notification has left, so the scope is empty, the
19849
+ * ladder resolves nothing and the delivery logged `no still could be resolved
19850
+ * … owners=[]`. A became-free edge has no owner BY CONSTRUCTION, and a
19851
+ * became-occupied edge can lack one too (every member parked, i.e.
19852
+ * `stationary:`) — so the gate is the ABSENCE itself, not the polarity. That
19853
+ * is the honest predicate: photograph exactly the edges whose media cannot be
19854
+ * resolved, and no others.
19855
+ *
19856
+ * **The capture is not awaited**, for the audio shelf's reason and one of its
19857
+ * own: a freed space is about to be taken by the next car, and a still fetched
19858
+ * after the outbox drained would show it occupied again.
19859
+ *
19860
+ * **The gate is the RULE's media policy AND the engine's own verdict.** The
19861
+ * watcher commits both polarities of every watched key, so a hub with only a
19862
+ * became-occupied rule still produces free edges — photographing those would
19863
+ * be a snapshot no notification can ever carry. `matchesOccupancy` is the same
19864
+ * predicate `evaluateRule` will apply, shared rather than restated. The rule
19865
+ * cache is an in-memory mirror, so this is not a fallible read (D49).
19866
+ */
19867
+ occupancyStillOwner(edge) {
19868
+ const shelf = this.stills;
19869
+ if (shelf === null || !this.evaluationActive) return void 0;
19870
+ if (edge.mediaTrackId !== void 0) return void 0;
19871
+ const subject = occupancySubjectFromEdge(edge);
19872
+ for (const rule of this.rules.listEnabled("device-event")) {
19873
+ if (rule.media.attach === "none") continue;
19874
+ const occ = rule.conditions.occupancy;
19875
+ if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
19876
+ const devices = rule.conditions.devices;
19877
+ if (devices !== void 0 && devices.length > 0 && !devices.includes(edge.deviceId)) continue;
19878
+ return shelf.capture(edge.deviceId, edge.timestamp, "occupancy");
19879
+ }
19765
19880
  }
19766
19881
  /**
19767
19882
  * Feed ONE audio inference frame into the sampling-window matcher.
@@ -20728,7 +20843,7 @@ var NotificationCenter = class NotificationCenter {
20728
20843
  kind,
20729
20844
  ruleId: rule.id,
20730
20845
  rule: rule.name,
20731
- snapshotWired: this.audioStills !== null
20846
+ snapshotWired: this.stills !== null
20732
20847
  }
20733
20848
  });
20734
20849
  }
@@ -20893,6 +21008,7 @@ var NotificationCenter = class NotificationCenter {
20893
21008
  if (subject.kind === "audio-window" || subject.kind === "audio-event") return { outcome: "disabled" };
20894
21009
  const trackId = subject.trackId;
20895
21010
  if (trackId === void 0) return { outcome: "disabled" };
21011
+ if (isStillShelfId(trackId)) return { outcome: "disabled" };
20896
21012
  const member = {
20897
21013
  trackId,
20898
21014
  className: subject.classNames[0] ?? "event",
@@ -24856,6 +24972,188 @@ function clusterByEmbedding(items, threshold = .5) {
24856
24972
  })).toSorted((a, b) => b.size - a.size || b.cohesion - a.cohesion);
24857
24973
  }
24858
24974
  //#endregion
24975
+ //#region src/pipeline-analytics/pipeline/face-matcher.ts
24976
+ /**
24977
+ * Count enrolled samples per identity for probes of a matching model+dimension.
24978
+ * Only identities meeting `minIdentitySamples` are eligible auto-match targets.
24979
+ */
24980
+ function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples) {
24981
+ const counts = /* @__PURE__ */ new Map();
24982
+ for (const s of gallery) {
24983
+ if (s.modelId !== probeModelId) continue;
24984
+ if (s.embedding.length !== probeDim) continue;
24985
+ counts.set(s.identityId, (counts.get(s.identityId) ?? 0) + 1);
24986
+ }
24987
+ const eligible = /* @__PURE__ */ new Set();
24988
+ for (const [id, count] of counts) if (count >= minIdentitySamples) eligible.add(id);
24989
+ return eligible;
24990
+ }
24991
+ /**
24992
+ * Match a probe embedding against the gallery. Only samples with the same
24993
+ * `modelId` AND the same dimension are compared (model-version safety). Returns
24994
+ * the best identity when its score ≥ threshold and it beats the best OTHER
24995
+ * identity by ≥ margin; otherwise null.
24996
+ */
24997
+ function matchEmbedding(probe, gallery, opts) {
24998
+ const probeVec = new Float32Array(probe.embedding);
24999
+ const eligible = eligibleIdentities(gallery, probe.modelId, probe.embedding.length, opts.minIdentitySamples ?? 1);
25000
+ const bestByIdentity = /* @__PURE__ */ new Map();
25001
+ for (const s of gallery) {
25002
+ if (s.modelId !== probe.modelId) continue;
25003
+ if (s.embedding.length !== probe.embedding.length) continue;
25004
+ if (!eligible.has(s.identityId)) continue;
25005
+ const score = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
25006
+ const prev = bestByIdentity.get(s.identityId);
25007
+ if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
25008
+ }
25009
+ if (bestByIdentity.size === 0) return null;
25010
+ let bestId = null;
25011
+ let bestScore = -Infinity;
25012
+ let secondScore = -Infinity;
25013
+ for (const [id, score] of bestByIdentity) if (score > bestScore) {
25014
+ secondScore = bestScore;
25015
+ bestScore = score;
25016
+ bestId = id;
25017
+ } else if (score > secondScore) secondScore = score;
25018
+ if (bestId === null || bestScore < opts.threshold) return null;
25019
+ if (secondScore > -Infinity && bestScore - secondScore < opts.margin) return null;
25020
+ return {
25021
+ identityId: bestId,
25022
+ score: bestScore
25023
+ };
25024
+ }
25025
+ /**
25026
+ * Assign at most one identity per track AND at most one track per identity for
25027
+ * a single frame. Greedy by score: compute every candidate's full ranked match
25028
+ * list, then repeatedly take the globally-highest (track, identity) pair whose
25029
+ * track and identity are both still free, until none qualify. A losing track
25030
+ * falls back to its next-best free identity ≥ threshold. Ties resolve by input order.
25031
+ */
25032
+ /**
25033
+ * Enrollment bound: the maximum cosine an embedding may score against a sample
25034
+ * of a DIFFERENT identity and still be enrolled. Genuine ArcFace cross-person
25035
+ * cosines sit under ~0.4; the low-information attractor (IR, blur, backs of
25036
+ * heads) sits at 0.95+. Measured 2026-08-18: 22 of 50 live samples were
25037
+ * enrolled from that attractor, with inter-identity cosines up to 0.988 — each
25038
+ * one a magnet that "recognises" every degraded probe as its owner.
25039
+ */
25040
+ var ENROLLMENT_MAX_FOREIGN_COSINE = .9;
25041
+ /**
25042
+ * Gate an embedding BEFORE it becomes a gallery sample: condemned when its
25043
+ * best cosine against any sample of ANOTHER identity (same model + dimension)
25044
+ * reaches {@link ENROLLMENT_MAX_FOREIGN_COSINE}. Null = safe to enroll.
25045
+ * Closeness to the TARGET identity's own samples is expected and never
25046
+ * condemns.
25047
+ */
25048
+ function enrollmentPromiscuity(embedding, modelId, targetIdentityId, gallery, bound = ENROLLMENT_MAX_FOREIGN_COSINE) {
25049
+ const probeVec = new Float32Array(embedding);
25050
+ let worst = null;
25051
+ for (const s of gallery) {
25052
+ if (s.identityId === targetIdentityId) continue;
25053
+ if (s.modelId !== modelId) continue;
25054
+ if (s.embedding.length !== embedding.length) continue;
25055
+ const cosine = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
25056
+ if (cosine >= bound && (worst === null || cosine > worst.cosine)) worst = {
25057
+ identityId: s.identityId,
25058
+ cosine
25059
+ };
25060
+ }
25061
+ return worst;
25062
+ }
25063
+ function assignUniquePerFrame(candidates, gallery, opts) {
25064
+ const pairs = [];
25065
+ candidates.forEach((c, trackIdx) => {
25066
+ const probeVec = new Float32Array(c.embedding);
25067
+ const eligible = eligibleIdentities(gallery, c.modelId, c.embedding.length, opts.minIdentitySamples ?? 1);
25068
+ const bestByIdentity = /* @__PURE__ */ new Map();
25069
+ for (const s of gallery) {
25070
+ if (s.modelId !== c.modelId) continue;
25071
+ if (s.embedding.length !== c.embedding.length) continue;
25072
+ if (!eligible.has(s.identityId)) continue;
25073
+ const score = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
25074
+ const prev = bestByIdentity.get(s.identityId);
25075
+ if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
25076
+ }
25077
+ let bestId = null;
25078
+ let bestScore = -1;
25079
+ let secondScore = -1;
25080
+ for (const [identityId, score] of bestByIdentity) if (score > bestScore) {
25081
+ if (bestId !== null) secondScore = bestScore;
25082
+ bestId = identityId;
25083
+ bestScore = score;
25084
+ } else if (score > secondScore) secondScore = score;
25085
+ const ambiguous = secondScore >= 0 && bestScore - secondScore < opts.margin;
25086
+ if (bestId !== null && !ambiguous) {
25087
+ for (const [identityId, score] of bestByIdentity) if (score >= opts.threshold) pairs.push({
25088
+ trackIdx,
25089
+ identityId,
25090
+ score
25091
+ });
25092
+ }
25093
+ });
25094
+ pairs.sort((a, b) => b.score - a.score);
25095
+ const takenTracks = /* @__PURE__ */ new Set();
25096
+ const takenIdentities = /* @__PURE__ */ new Set();
25097
+ const result = /* @__PURE__ */ new Map();
25098
+ for (const p of pairs) {
25099
+ if (takenTracks.has(p.trackIdx) || takenIdentities.has(p.identityId)) continue;
25100
+ takenTracks.add(p.trackIdx);
25101
+ takenIdentities.add(p.identityId);
25102
+ result.set(candidates[p.trackIdx].trackId, {
25103
+ identityId: p.identityId,
25104
+ score: p.score
25105
+ });
25106
+ }
25107
+ return result;
25108
+ }
25109
+ var EMPTY_AGGREGATE = Object.freeze({
25110
+ assignedIdentityId: null,
25111
+ confirmations: Object.freeze({})
25112
+ });
25113
+ /**
25114
+ * Fold one frame's match into a track's aggregate. A null match leaves state
25115
+ * untouched. An identity is assigned once its cumulative confirmation count
25116
+ * reaches `confirmFrames`; once assigned, a different identity must independently
25117
+ * reach `confirmFrames` to switch. `changed` is true only on a (re)assignment to
25118
+ * a NEW identity, so callers fire the label update exactly once per transition.
25119
+ *
25120
+ * On each (re)assignment the confirmations record is reset to contain only the
25121
+ * newly-assigned identity's count, so any challenger must independently re-reach
25122
+ * `confirmFrames` from scratch after any assignment.
25123
+ */
25124
+ function updateTrackAggregate(prev, match, opts) {
25125
+ if (match === null) return {
25126
+ state: prev,
25127
+ changed: false
25128
+ };
25129
+ const count = (prev.confirmations[match.identityId] ?? 0) + 1;
25130
+ const confirmations = {
25131
+ ...prev.confirmations,
25132
+ [match.identityId]: count
25133
+ };
25134
+ if (count < opts.confirmFrames) return {
25135
+ state: {
25136
+ ...prev,
25137
+ confirmations
25138
+ },
25139
+ changed: false
25140
+ };
25141
+ if (prev.assignedIdentityId === match.identityId) return {
25142
+ state: {
25143
+ ...prev,
25144
+ confirmations
25145
+ },
25146
+ changed: false
25147
+ };
25148
+ return {
25149
+ state: {
25150
+ assignedIdentityId: match.identityId,
25151
+ confirmations: { [match.identityId]: count }
25152
+ },
25153
+ changed: true
25154
+ };
25155
+ }
25156
+ //#endregion
24859
25157
  //#region src/pipeline-analytics/face-gallery-provider.ts
24860
25158
  /** Attribution of a face identity NAMED BY THE OPERATOR in the gallery. */
24861
25159
  var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
@@ -25102,6 +25400,19 @@ var FaceGalleryProvider = class {
25102
25400
  }
25103
25401
  const currentFace = await this.faceStore.get(faceId);
25104
25402
  if (!currentFace) throw new Error(`FaceGalleryProvider.assignFace: face disappeared after pre-cleanup: ${faceId}`);
25403
+ const verdict = enrollmentPromiscuity(currentFace.embedding, MODEL_ID, identityId, await this.identityStore.loadGallery());
25404
+ if (verdict !== null) {
25405
+ this.logger.warn("assignFace refused: embedding too close to a FOREIGN identity sample", {
25406
+ tags: { deviceId: currentFace.deviceId },
25407
+ meta: {
25408
+ faceId,
25409
+ identityId,
25410
+ foreignIdentityId: verdict.identityId,
25411
+ cosine: verdict.cosine
25412
+ }
25413
+ });
25414
+ 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`);
25415
+ }
25105
25416
  let newKey;
25106
25417
  if (currentFace.mediaKey) {
25107
25418
  newKey = await this.mediaStore.reown({
@@ -33552,146 +33863,6 @@ async function resolveDefaultEventMedia(deps) {
33552
33863
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
33553
33864
  }
33554
33865
  //#endregion
33555
- //#region src/pipeline-analytics/pipeline/face-matcher.ts
33556
- /**
33557
- * Count enrolled samples per identity for probes of a matching model+dimension.
33558
- * Only identities meeting `minIdentitySamples` are eligible auto-match targets.
33559
- */
33560
- function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples) {
33561
- const counts = /* @__PURE__ */ new Map();
33562
- for (const s of gallery) {
33563
- if (s.modelId !== probeModelId) continue;
33564
- if (s.embedding.length !== probeDim) continue;
33565
- counts.set(s.identityId, (counts.get(s.identityId) ?? 0) + 1);
33566
- }
33567
- const eligible = /* @__PURE__ */ new Set();
33568
- for (const [id, count] of counts) if (count >= minIdentitySamples) eligible.add(id);
33569
- return eligible;
33570
- }
33571
- /**
33572
- * Match a probe embedding against the gallery. Only samples with the same
33573
- * `modelId` AND the same dimension are compared (model-version safety). Returns
33574
- * the best identity when its score ≥ threshold and it beats the best OTHER
33575
- * identity by ≥ margin; otherwise null.
33576
- */
33577
- function matchEmbedding(probe, gallery, opts) {
33578
- const probeVec = new Float32Array(probe.embedding);
33579
- const eligible = eligibleIdentities(gallery, probe.modelId, probe.embedding.length, opts.minIdentitySamples ?? 1);
33580
- const bestByIdentity = /* @__PURE__ */ new Map();
33581
- for (const s of gallery) {
33582
- if (s.modelId !== probe.modelId) continue;
33583
- if (s.embedding.length !== probe.embedding.length) continue;
33584
- if (!eligible.has(s.identityId)) continue;
33585
- const score = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
33586
- const prev = bestByIdentity.get(s.identityId);
33587
- if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
33588
- }
33589
- if (bestByIdentity.size === 0) return null;
33590
- let bestId = null;
33591
- let bestScore = -Infinity;
33592
- let secondScore = -Infinity;
33593
- for (const [id, score] of bestByIdentity) if (score > bestScore) {
33594
- secondScore = bestScore;
33595
- bestScore = score;
33596
- bestId = id;
33597
- } else if (score > secondScore) secondScore = score;
33598
- if (bestId === null || bestScore < opts.threshold) return null;
33599
- if (secondScore > -Infinity && bestScore - secondScore < opts.margin) return null;
33600
- return {
33601
- identityId: bestId,
33602
- score: bestScore
33603
- };
33604
- }
33605
- /**
33606
- * Assign at most one identity per track AND at most one track per identity for
33607
- * a single frame. Greedy by score: compute every candidate's full ranked match
33608
- * list, then repeatedly take the globally-highest (track, identity) pair whose
33609
- * track and identity are both still free, until none qualify. A losing track
33610
- * falls back to its next-best free identity ≥ threshold. Ties resolve by input order.
33611
- */
33612
- function assignUniquePerFrame(candidates, gallery, opts) {
33613
- const pairs = [];
33614
- candidates.forEach((c, trackIdx) => {
33615
- const probeVec = new Float32Array(c.embedding);
33616
- const eligible = eligibleIdentities(gallery, c.modelId, c.embedding.length, opts.minIdentitySamples ?? 1);
33617
- const bestByIdentity = /* @__PURE__ */ new Map();
33618
- for (const s of gallery) {
33619
- if (s.modelId !== c.modelId) continue;
33620
- if (s.embedding.length !== c.embedding.length) continue;
33621
- if (!eligible.has(s.identityId)) continue;
33622
- const score = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
33623
- const prev = bestByIdentity.get(s.identityId);
33624
- if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
33625
- }
33626
- for (const [identityId, score] of bestByIdentity) if (score >= opts.threshold) pairs.push({
33627
- trackIdx,
33628
- identityId,
33629
- score
33630
- });
33631
- });
33632
- pairs.sort((a, b) => b.score - a.score);
33633
- const takenTracks = /* @__PURE__ */ new Set();
33634
- const takenIdentities = /* @__PURE__ */ new Set();
33635
- const result = /* @__PURE__ */ new Map();
33636
- for (const p of pairs) {
33637
- if (takenTracks.has(p.trackIdx) || takenIdentities.has(p.identityId)) continue;
33638
- takenTracks.add(p.trackIdx);
33639
- takenIdentities.add(p.identityId);
33640
- result.set(candidates[p.trackIdx].trackId, {
33641
- identityId: p.identityId,
33642
- score: p.score
33643
- });
33644
- }
33645
- return result;
33646
- }
33647
- var EMPTY_AGGREGATE = Object.freeze({
33648
- assignedIdentityId: null,
33649
- confirmations: Object.freeze({})
33650
- });
33651
- /**
33652
- * Fold one frame's match into a track's aggregate. A null match leaves state
33653
- * untouched. An identity is assigned once its cumulative confirmation count
33654
- * reaches `confirmFrames`; once assigned, a different identity must independently
33655
- * reach `confirmFrames` to switch. `changed` is true only on a (re)assignment to
33656
- * a NEW identity, so callers fire the label update exactly once per transition.
33657
- *
33658
- * On each (re)assignment the confirmations record is reset to contain only the
33659
- * newly-assigned identity's count, so any challenger must independently re-reach
33660
- * `confirmFrames` from scratch after any assignment.
33661
- */
33662
- function updateTrackAggregate(prev, match, opts) {
33663
- if (match === null) return {
33664
- state: prev,
33665
- changed: false
33666
- };
33667
- const count = (prev.confirmations[match.identityId] ?? 0) + 1;
33668
- const confirmations = {
33669
- ...prev.confirmations,
33670
- [match.identityId]: count
33671
- };
33672
- if (count < opts.confirmFrames) return {
33673
- state: {
33674
- ...prev,
33675
- confirmations
33676
- },
33677
- changed: false
33678
- };
33679
- if (prev.assignedIdentityId === match.identityId) return {
33680
- state: {
33681
- ...prev,
33682
- confirmations
33683
- },
33684
- changed: false
33685
- };
33686
- return {
33687
- state: {
33688
- assignedIdentityId: match.identityId,
33689
- confirmations: { [match.identityId]: count }
33690
- },
33691
- changed: true
33692
- };
33693
- }
33694
- //#endregion
33695
33866
  //#region src/pipeline-analytics/face-recognizer.ts
33696
33867
  /** Attribution of an identity resolved by the in-process ArcFace matcher. */
33697
33868
  var FACE_RECOGNIZER_STEP_ID = "face-recognizer";
@@ -52396,6 +52567,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
52396
52567
  },
52397
52568
  renderEventMedia: (req) => this.renderEventMedia(req),
52398
52569
  publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
52570
+ viewerUrlBase: async () => pickArtifactBaseUrl(await this.ncEndpoints()),
52399
52571
  indexArtifact: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? {
52400
52572
  url: null,
52401
52573
  artifactId: null