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