@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.
- package/dist/{dist-BdojoP7i.js → dist-BQsMSX2A.js} +29 -3
- package/dist/{dist-C-_TL-I1.mjs → dist-ecY_-nDx.mjs} +29 -3
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CeFPqm3h.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BdX09HCw.mjs} +3 -3
- package/dist/pipeline-analytics/{hostInit-BExsGl4h.mjs → hostInit-DULHUyr1.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +402 -230
- package/dist/pipeline-analytics/index.mjs +402 -230
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -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-
|
|
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/
|
|
5565
|
+
//#region src/notification-center/still-shelf.ts
|
|
5566
5566
|
/**
|
|
5567
|
-
* The
|
|
5567
|
+
* The still shelf — the PHOTOGRAPH a notification carries when nothing it can
|
|
5568
|
+
* name owns a frame.
|
|
5568
5569
|
*
|
|
5569
|
-
* ## Why a
|
|
5570
|
+
* ## Why a trigger needs one at all
|
|
5570
5571
|
*
|
|
5571
|
-
* The attachment ladder resolves media by OWNER, and
|
|
5572
|
-
*
|
|
5573
|
-
*
|
|
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`).
|
|
5576
|
-
*
|
|
5577
|
-
*
|
|
5578
|
-
*
|
|
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
|
|
5582
|
-
*
|
|
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
|
|
5591
|
-
* the digest's `listTracks`, retention, and the audio-
|
|
5592
|
-
* operator ceilings (`audio-marker-projector.ts` exists
|
|
5593
|
-
* many audio markers a camera may emit). A notification
|
|
5594
|
-
* timeline history as a side effect of wanting a picture.
|
|
5595
|
-
*
|
|
5596
|
-
* **The capture STARTS immediately and is never awaited.**
|
|
5597
|
-
* — a scream is over before a snapshot round-trip completes
|
|
5598
|
-
*
|
|
5599
|
-
*
|
|
5600
|
-
*
|
|
5601
|
-
*
|
|
5602
|
-
*
|
|
5603
|
-
*
|
|
5604
|
-
*
|
|
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
|
|
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
|
|
5619
|
-
* `getMediaForOwner` answers for
|
|
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
|
|
5639
|
+
var NC_STILL_SHELF_PREFIX = "nc-still:";
|
|
5622
5640
|
/** True for an owner id this shelf minted. */
|
|
5623
|
-
function
|
|
5624
|
-
return id.startsWith(
|
|
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
|
|
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
|
|
5639
|
-
*
|
|
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
|
|
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
|
|
5656
|
-
* subject should name. SYNCHRONOUS by contract: the caller is on the
|
|
5657
|
-
*
|
|
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 = `${
|
|
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 +
|
|
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:
|
|
5751
|
+
files: stillShelfMedia(data, atMs)
|
|
5733
5752
|
});
|
|
5734
|
-
this.deps.logger.info(
|
|
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(
|
|
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: {
|
|
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
|
|
5791
|
+
function stillShelfMedia(data, timestamp) {
|
|
5769
5792
|
const base64 = data.toString("base64");
|
|
5770
5793
|
const file = (kind) => ({
|
|
5771
|
-
key: `${
|
|
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 (`
|
|
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.
|
|
7520
|
-
*
|
|
7521
|
-
|
|
7522
|
-
|
|
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
|
-
...
|
|
7571
|
+
...mediaOwner !== void 0 ? { trackId: mediaOwner } : {},
|
|
7531
7572
|
source: "pipeline",
|
|
7532
|
-
occupancy:
|
|
7533
|
-
|
|
7534
|
-
|
|
7535
|
-
|
|
7536
|
-
|
|
7537
|
-
|
|
7538
|
-
|
|
7539
|
-
|
|
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 (`
|
|
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
|
-
/**
|
|
9949
|
-
|
|
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
|
-
* (`
|
|
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
|
|
18748
|
-
*
|
|
18749
|
-
*
|
|
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
|
-
|
|
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.
|
|
19121
|
+
this.stills = getSnapshot !== void 0 ? new NcStillShelf({
|
|
19049
19122
|
getSnapshot,
|
|
19050
|
-
logger: this.logger.child("
|
|
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
|
|
19141
|
-
if (
|
|
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.
|
|
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.
|
|
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.
|
|
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
|