@camstack/addon-post-analysis 1.2.106 → 1.2.108
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-FAVcx3hJ.js → dist-BV5xFo8f.js} +76 -6
- package/dist/{dist-DiU9qiuA.mjs → dist-DFNQ5VB_.mjs} +76 -6
- 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-C7vEGOd1.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-v1VQLSa-.mjs} +3 -3
- package/dist/pipeline-analytics/{hostInit-DLdsDxyX.mjs → hostInit-CnJC02T3.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +1701 -740
- package/dist/pipeline-analytics/index.mjs +1699 -740
- 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-BV5xFo8f.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");
|
|
@@ -5574,279 +5574,6 @@ function observeLabel(deviceId, spec, sample, now) {
|
|
|
5574
5574
|
spec
|
|
5575
5575
|
};
|
|
5576
5576
|
}
|
|
5577
|
-
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
5578
|
-
var FULL_FRAME_QUALITY = 80;
|
|
5579
|
-
/**
|
|
5580
|
-
* Downscale an already-encoded JPEG full frame to FIT WITHIN
|
|
5581
|
-
* {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
|
|
5582
|
-
* ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
|
|
5583
|
-
* box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
|
|
5584
|
-
* track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
|
|
5585
|
-
* at night) is never stored or served — the privacy fix moved to CAPTURE time.
|
|
5586
|
-
*/
|
|
5587
|
-
async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
5588
|
-
return (0, sharp.default)(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
|
|
5589
|
-
fit: "inside",
|
|
5590
|
-
withoutEnlargement: true
|
|
5591
|
-
}).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
|
|
5592
|
-
}
|
|
5593
|
-
//#endregion
|
|
5594
|
-
//#region src/notification-center/still-shelf.ts
|
|
5595
|
-
/**
|
|
5596
|
-
* The still shelf — the PHOTOGRAPH a notification carries when nothing it can
|
|
5597
|
-
* name owns a frame.
|
|
5598
|
-
*
|
|
5599
|
-
* ## Why a trigger needs one at all
|
|
5600
|
-
*
|
|
5601
|
-
* The attachment ladder resolves media by OWNER, and most triggers have one: an
|
|
5602
|
-
* object or package event owns its crops, a closed track owns its best shot, a
|
|
5603
|
-
* doorbell press owns the marker track the same press projected
|
|
5604
|
-
* (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
|
|
5605
|
-
* counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
|
|
5606
|
-
* same reason in both cases — the subject is an ABSENCE:
|
|
5607
|
-
*
|
|
5608
|
-
* - an **audio** match: nothing was boxed, nothing was tracked, and —
|
|
5609
|
-
* deliberately — nothing is persisted at all. A confirmed window is a claim
|
|
5610
|
-
* about sound that has already stopped, and `event-intake.ts` states why
|
|
5611
|
-
* replaying it later would be wrong.
|
|
5612
|
-
* - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
|
|
5613
|
-
* whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
|
|
5614
|
-
* nobody and the ladder logs `no still could be resolved … owners=[]`.
|
|
5615
|
-
*
|
|
5616
|
-
* So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
|
|
5617
|
-
* of the trigger. Not of the sound, not of the object that left — of what the
|
|
5618
|
-
* camera can see now that it happened. For a freed parking space that is
|
|
5619
|
-
* exactly the answer the operator wants: the space, empty.
|
|
5620
|
-
*
|
|
5621
|
-
* **One shelf, not two.** The mechanism is identical down to the reuse window,
|
|
5622
|
-
* and the only thing that differs between the two triggers is the GATE deciding
|
|
5623
|
-
* that a photograph is owed at all — which belongs at the trigger site, where
|
|
5624
|
-
* the rules are, and not here. A twin module would be a second derivation of
|
|
5625
|
-
* one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
|
|
5626
|
-
* can say which absence they are about.
|
|
5627
|
-
*
|
|
5628
|
-
* ## Three properties, and each one is a decision
|
|
5629
|
-
*
|
|
5630
|
-
* **It is not a record.** The bytes live here, in RAM, under an owner id and a
|
|
5631
|
-
* TTL that covers the outbox's whole retry horizon — and nowhere else. The
|
|
5632
|
-
* alternative was the doorbell's: materialise a synthetic marker track through
|
|
5633
|
-
* `SyntheticTrackMaterializer` and let the notification name it. That would put
|
|
5634
|
-
* a durable Track on the camera's timeline for every confirmed window and every
|
|
5635
|
-
* emptied zone, feeding the digest's `listTracks`, retention, and the audio-
|
|
5636
|
-
* marker feature's own operator ceilings (`audio-marker-projector.ts` exists
|
|
5637
|
-
* precisely to bound how many audio markers a camera may emit). A notification
|
|
5638
|
-
* must not manufacture timeline history as a side effect of wanting a picture.
|
|
5639
|
-
*
|
|
5640
|
-
* **The capture STARTS immediately and is never awaited.** The subject is
|
|
5641
|
-
* transient — a scream is over before a snapshot round-trip completes, and a
|
|
5642
|
-
* freed space is about to be taken by the next car — so the fetch is kicked off
|
|
5643
|
-
* at the trigger, before the rule evaluation runs, and the owner id is minted
|
|
5644
|
-
* synchronously so the outbox row can name it. The bytes land while the row
|
|
5645
|
-
* waits in the queue, and the dispatcher's existing bounded still-wait (or the
|
|
5646
|
-
* pause its own footage render already costs) picks them up. A camera that
|
|
5647
|
-
* never answers costs the picture and never the notification.
|
|
5648
|
-
*
|
|
5649
|
-
* **Two triggers seconds apart share ONE capture.** A barking dog confirms
|
|
5650
|
-
* repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
|
|
5651
|
-
* rule's cooldown is its only brake, and the cooldown is applied AFTER this.
|
|
5652
|
-
* Without a reuse window this would photograph a camera at whatever rate the
|
|
5653
|
-
* sound happens to occur. Each trigger still gets its OWN owner id, so two
|
|
5654
|
-
* outbox rows are never mistaken for one subject; they merely point at the same
|
|
5655
|
-
* frame, which is the truth — the scene did not change in ten seconds. The
|
|
5656
|
-
* window is per CAMERA and trigger-agnostic for the same reason: a sound and an
|
|
5657
|
-
* emptied zone ten seconds apart are two claims about one scene.
|
|
5658
|
-
*
|
|
5659
|
-
* Nothing here is silent: a capture that lands and a camera that refuses each
|
|
5660
|
-
* emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
|
|
5661
|
-
* and 615 not" is the only form that question is ever asked in.
|
|
5662
|
-
*/
|
|
5663
|
-
/**
|
|
5664
|
-
* The owner-id namespace. It is what routes a lookup here instead of to the
|
|
5665
|
-
* media store, and it is the reason the dispatcher needs no audio or occupancy
|
|
5666
|
-
* branch — `getMediaForOwner` answers for all of them under one signature.
|
|
5667
|
-
*/
|
|
5668
|
-
var NC_STILL_SHELF_PREFIX = "nc-still:";
|
|
5669
|
-
/** True for an owner id this shelf minted. */
|
|
5670
|
-
function isStillShelfId(id) {
|
|
5671
|
-
return id.startsWith(NC_STILL_SHELF_PREFIX);
|
|
5672
|
-
}
|
|
5673
|
-
/**
|
|
5674
|
-
* How long the bytes are held.
|
|
5675
|
-
*
|
|
5676
|
-
* The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
|
|
5677
|
-
* ten minutes; fifteen covers that with room for a slow drain. Past it the row
|
|
5678
|
-
* ships text-only, which is the correct degradation for a photograph of a scene
|
|
5679
|
-
* that is a quarter of an hour stale anyway.
|
|
5680
|
-
*/
|
|
5681
|
-
var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
|
|
5682
|
-
/** A hanging snapshot cap must not pin a capture slot forever. */
|
|
5683
|
-
var SNAPSHOT_TIMEOUT_MS = 8e3;
|
|
5684
|
-
/**
|
|
5685
|
-
* Hard bound on held captures. Reached only if every camera on the hub triggers
|
|
5686
|
-
* one inside a single TTL; the oldest is dropped first, which costs a
|
|
5687
|
-
* fifteen-minute-old picture nobody is waiting for.
|
|
5688
|
-
*/
|
|
5689
|
-
var MAX_CAPTURES = 64;
|
|
5690
|
-
var NcStillShelf = class {
|
|
5691
|
-
deps;
|
|
5692
|
-
/** captureId → the photograph. */
|
|
5693
|
-
captures = /* @__PURE__ */ new Map();
|
|
5694
|
-
/** ownerId → captureId. Several owners may name one capture (the reuse window). */
|
|
5695
|
-
owners = /* @__PURE__ */ new Map();
|
|
5696
|
-
/** deviceId → its newest capture, for the reuse window. */
|
|
5697
|
-
newest = /* @__PURE__ */ new Map();
|
|
5698
|
-
constructor(deps) {
|
|
5699
|
-
this.deps = deps;
|
|
5700
|
-
}
|
|
5701
|
-
/**
|
|
5702
|
-
* Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
|
|
5703
|
-
* subject should name. SYNCHRONOUS by contract: the caller is on the trigger
|
|
5704
|
-
* path and the outbox row is built from what this returns.
|
|
5705
|
-
*/
|
|
5706
|
-
capture(deviceId, atMs, trigger) {
|
|
5707
|
-
const now = this.deps.now();
|
|
5708
|
-
this.prune(now);
|
|
5709
|
-
const ownerId = `${NC_STILL_SHELF_PREFIX}${(0, node_crypto.randomUUID)()}`;
|
|
5710
|
-
const recent = this.newest.get(deviceId);
|
|
5711
|
-
if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
|
|
5712
|
-
this.owners.set(ownerId, recent.captureId);
|
|
5713
|
-
return ownerId;
|
|
5714
|
-
}
|
|
5715
|
-
const captureId = (0, node_crypto.randomUUID)();
|
|
5716
|
-
this.captures.set(captureId, {
|
|
5717
|
-
deviceId,
|
|
5718
|
-
trigger,
|
|
5719
|
-
startedAt: now,
|
|
5720
|
-
expiresAt: now + NC_STILL_SHELF_TTL_MS,
|
|
5721
|
-
files: []
|
|
5722
|
-
});
|
|
5723
|
-
this.newest.set(deviceId, {
|
|
5724
|
-
captureId,
|
|
5725
|
-
at: now
|
|
5726
|
-
});
|
|
5727
|
-
this.owners.set(ownerId, captureId);
|
|
5728
|
-
this.fetch(captureId, deviceId, atMs, trigger);
|
|
5729
|
-
return ownerId;
|
|
5730
|
-
}
|
|
5731
|
-
/**
|
|
5732
|
-
* The media an owner id holds, or `undefined` when this shelf never minted
|
|
5733
|
-
* it. An EMPTY array is a different answer: the capture exists and has not
|
|
5734
|
-
* landed (or never will), which is exactly the case the dispatcher's bounded
|
|
5735
|
-
* wait and its `no still could be resolved` line are for.
|
|
5736
|
-
*/
|
|
5737
|
-
get(ownerId) {
|
|
5738
|
-
const captureId = this.owners.get(ownerId);
|
|
5739
|
-
if (captureId === void 0) return void 0;
|
|
5740
|
-
return this.captures.get(captureId)?.files ?? [];
|
|
5741
|
-
}
|
|
5742
|
-
/** Drop expired captures and the owners that named them. */
|
|
5743
|
-
prune(now) {
|
|
5744
|
-
for (const [captureId, held] of this.captures) {
|
|
5745
|
-
if (held.expiresAt > now) continue;
|
|
5746
|
-
this.captures.delete(captureId);
|
|
5747
|
-
if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
|
|
5748
|
-
}
|
|
5749
|
-
while (this.captures.size > MAX_CAPTURES) {
|
|
5750
|
-
const oldest = this.captures.keys().next();
|
|
5751
|
-
if (oldest.done === true) break;
|
|
5752
|
-
const held = this.captures.get(oldest.value);
|
|
5753
|
-
this.captures.delete(oldest.value);
|
|
5754
|
-
if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
|
|
5755
|
-
}
|
|
5756
|
-
for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
|
|
5757
|
-
}
|
|
5758
|
-
/** Drop everything (shutdown). */
|
|
5759
|
-
clear() {
|
|
5760
|
-
this.captures.clear();
|
|
5761
|
-
this.owners.clear();
|
|
5762
|
-
this.newest.clear();
|
|
5763
|
-
}
|
|
5764
|
-
async fetch(captureId, deviceId, atMs, trigger) {
|
|
5765
|
-
try {
|
|
5766
|
-
const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
|
|
5767
|
-
if (shot === null || shot.base64.length === 0) {
|
|
5768
|
-
this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
|
|
5769
|
-
return;
|
|
5770
|
-
}
|
|
5771
|
-
const raw = Buffer.from(shot.base64, "base64");
|
|
5772
|
-
let data = raw;
|
|
5773
|
-
try {
|
|
5774
|
-
data = await downscaleFullFrameJpeg(raw, 960, 540);
|
|
5775
|
-
} catch {}
|
|
5776
|
-
const held = this.captures.get(captureId);
|
|
5777
|
-
if (held === void 0) return;
|
|
5778
|
-
this.captures.set(captureId, {
|
|
5779
|
-
...held,
|
|
5780
|
-
files: stillShelfMedia(data, atMs)
|
|
5781
|
-
});
|
|
5782
|
-
this.deps.logger.info(`${trigger} still captured`, {
|
|
5783
|
-
tags: { deviceId },
|
|
5784
|
-
meta: {
|
|
5785
|
-
trigger,
|
|
5786
|
-
bytes: data.byteLength,
|
|
5787
|
-
tookMs: this.deps.now() - held.startedAt
|
|
5788
|
-
}
|
|
5789
|
-
});
|
|
5790
|
-
} catch (err) {
|
|
5791
|
-
this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
|
|
5792
|
-
}
|
|
5793
|
-
}
|
|
5794
|
-
/**
|
|
5795
|
-
* A branch that drops work says so. This one costs the operator the picture
|
|
5796
|
-
* on a notification they DID receive, so it is a warn and it carries the
|
|
5797
|
-
* camera — the only key the question is ever asked with.
|
|
5798
|
-
*/
|
|
5799
|
-
reportMiss(deviceId, trigger, reason) {
|
|
5800
|
-
this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
|
|
5801
|
-
tags: { deviceId },
|
|
5802
|
-
meta: {
|
|
5803
|
-
trigger,
|
|
5804
|
-
reason
|
|
5805
|
-
}
|
|
5806
|
-
});
|
|
5807
|
-
}
|
|
5808
|
-
};
|
|
5809
|
-
/**
|
|
5810
|
-
* The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
|
|
5811
|
-
*
|
|
5812
|
-
* `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
|
|
5813
|
-
* and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
|
|
5814
|
-
* `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
|
|
5815
|
-
* no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
|
|
5816
|
-
* NOTHING and the notification ships text-only with the ordinary line saying
|
|
5817
|
-
* so. Inventing a centre crop would answer a different question from the one
|
|
5818
|
-
* the operator asked.
|
|
5819
|
-
*/
|
|
5820
|
-
function stillShelfMedia(data, timestamp) {
|
|
5821
|
-
const base64 = data.toString("base64");
|
|
5822
|
-
const file = (kind) => ({
|
|
5823
|
-
key: `${NC_STILL_SHELF_PREFIX}${kind}`,
|
|
5824
|
-
kind,
|
|
5825
|
-
base64,
|
|
5826
|
-
sizeBytes: data.byteLength,
|
|
5827
|
-
timestamp
|
|
5828
|
-
});
|
|
5829
|
-
return [
|
|
5830
|
-
file("keyFrameSmall"),
|
|
5831
|
-
file("keyFrame"),
|
|
5832
|
-
file("fullFrame")
|
|
5833
|
-
];
|
|
5834
|
-
}
|
|
5835
|
-
/** Reject if `promise` does not settle within `ms`. */
|
|
5836
|
-
function withTimeout$3(promise, ms) {
|
|
5837
|
-
return new Promise((resolve, reject) => {
|
|
5838
|
-
const timer = setTimeout(() => {
|
|
5839
|
-
reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
|
|
5840
|
-
}, ms);
|
|
5841
|
-
promise.then((value) => {
|
|
5842
|
-
clearTimeout(timer);
|
|
5843
|
-
resolve(value);
|
|
5844
|
-
}, (err) => {
|
|
5845
|
-
clearTimeout(timer);
|
|
5846
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
5847
|
-
});
|
|
5848
|
-
});
|
|
5849
|
-
}
|
|
5850
5577
|
//#endregion
|
|
5851
5578
|
//#region src/shared/llm-vision/prompt-hygiene.ts
|
|
5852
5579
|
/**
|
|
@@ -8907,6 +8634,58 @@ var NcTextCatalog = class {
|
|
|
8907
8634
|
}
|
|
8908
8635
|
};
|
|
8909
8636
|
//#endregion
|
|
8637
|
+
//#region src/notification-center/trigger-zone-still.ts
|
|
8638
|
+
/**
|
|
8639
|
+
* How far a candidate's instant may sit from the nearest trail point before the
|
|
8640
|
+
* trail stops speaking for it.
|
|
8641
|
+
*
|
|
8642
|
+
* 2 s. The live detection cadence on this install is 100–300 ms and the widest
|
|
8643
|
+
* real gap inside a track is a couple of seconds (a session gap); beyond that
|
|
8644
|
+
* the nearest point is about a different moment, and answering "in the zone"
|
|
8645
|
+
* from it would be a guess. An unanswerable candidate is treated as NOT
|
|
8646
|
+
* qualifying — never as disqualifying the others, and never as a reason to ship
|
|
8647
|
+
* nothing.
|
|
8648
|
+
*/
|
|
8649
|
+
var TRAIL_MATCH_MAX_MS = 2e3;
|
|
8650
|
+
/** Overlap of a normalized box with the union of the zones — the maximum over
|
|
8651
|
+
* the polygons, which is what "any of these zones" means for one box. */
|
|
8652
|
+
function zoneOverlap(point, polygons) {
|
|
8653
|
+
let best = 0;
|
|
8654
|
+
for (const polygon of polygons) {
|
|
8655
|
+
if (polygon.length < 3) continue;
|
|
8656
|
+
const overlap = bboxPolygonOverlap(point.bbox, polygon);
|
|
8657
|
+
if (overlap > best) best = overlap;
|
|
8658
|
+
}
|
|
8659
|
+
return best;
|
|
8660
|
+
}
|
|
8661
|
+
/** The trail point closest to `timestamp`, or null when none is close enough. */
|
|
8662
|
+
function trailAt(trail, timestamp) {
|
|
8663
|
+
let best = null;
|
|
8664
|
+
let bestDelta = Number.POSITIVE_INFINITY;
|
|
8665
|
+
for (const point of trail) {
|
|
8666
|
+
const delta = Math.abs(point.timestamp - timestamp);
|
|
8667
|
+
if (delta < bestDelta) {
|
|
8668
|
+
bestDelta = delta;
|
|
8669
|
+
best = point;
|
|
8670
|
+
}
|
|
8671
|
+
}
|
|
8672
|
+
return best !== null && bestDelta <= 2e3 ? best : null;
|
|
8673
|
+
}
|
|
8674
|
+
/**
|
|
8675
|
+
* The candidates that show the subject inside the triggering zone, in the order
|
|
8676
|
+
* they were given. Every candidate comes back unchanged when the question
|
|
8677
|
+
* cannot be asked (no trail, no polygon) or when nobody qualifies.
|
|
8678
|
+
*/
|
|
8679
|
+
function selectTriggerZoneCandidates(input) {
|
|
8680
|
+
const { candidates, trail, polygons } = input;
|
|
8681
|
+
if (candidates.length === 0 || trail.length === 0 || polygons.length === 0) return candidates;
|
|
8682
|
+
const qualifying = candidates.filter((candidate) => {
|
|
8683
|
+
const point = trailAt(trail, candidate.timestamp);
|
|
8684
|
+
return point !== null && zoneOverlap(point, polygons) > 0;
|
|
8685
|
+
});
|
|
8686
|
+
return qualifying.length > 0 ? qualifying : candidates;
|
|
8687
|
+
}
|
|
8688
|
+
//#endregion
|
|
8910
8689
|
//#region src/notification-center/dispatcher.ts
|
|
8911
8690
|
var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
8912
8691
|
/**
|
|
@@ -8920,6 +8699,28 @@ var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
|
8920
8699
|
* text-only rather than holding the alarm any longer.
|
|
8921
8700
|
*/
|
|
8922
8701
|
var NC_STILL_WAIT_MS = 3e3;
|
|
8702
|
+
/**
|
|
8703
|
+
* How far a still's OWN instant may sit from the trigger and still count as
|
|
8704
|
+
* being ABOUT the trigger, for a `delivery: 'immediate'` rule.
|
|
8705
|
+
*
|
|
8706
|
+
* Derived from {@link TRAIL_MATCH_MAX_MS} rather than repeated: that constant
|
|
8707
|
+
* already answers "can the subject's trail speak for this instant", and the two
|
|
8708
|
+
* gates run back to back on the same candidate list (moment, then place). If
|
|
8709
|
+
* they disagreed, a frame could be judged in-zone for a moment the age gate had
|
|
8710
|
+
* already ruled a different one.
|
|
8711
|
+
*
|
|
8712
|
+
* The value is measured, not chosen: on the 54 live fires of 'Persona su Uscio'
|
|
8713
|
+
* a full-scene frame within ±1 s of the trigger existed for 52 (96%) and within
|
|
8714
|
+
* ±2 s for 53 (98%), while the frame that produced the operator's report was
|
|
8715
|
+
* +4901 ms. Widening this to 5 s would re-admit exactly that frame.
|
|
8716
|
+
*
|
|
8717
|
+
* It is NOT the ladder's top rung that has to be in band — that rung is the
|
|
8718
|
+
* native keyFrame, whose capture misses and retries (36 misses over 105 track
|
|
8719
|
+
* starts on device 615 in 6 h), so it is in band only 46% of the time. Holding
|
|
8720
|
+
* out for the KIND the rule asked for would wait for the late frame. The gate
|
|
8721
|
+
* is the instant; the ladder then runs among the frames that are about it.
|
|
8722
|
+
*/
|
|
8723
|
+
var NC_TRIGGER_STILL_MAX_AGE_MS = TRAIL_MATCH_MAX_MS;
|
|
8923
8724
|
var NcDispatcher = class {
|
|
8924
8725
|
deps;
|
|
8925
8726
|
targetCache = null;
|
|
@@ -9502,9 +9303,10 @@ var NcDispatcher = class {
|
|
|
9502
9303
|
const out = [];
|
|
9503
9304
|
const zoneIdsWanted = entry.payload.mediaZoneIds;
|
|
9504
9305
|
const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
|
|
9505
|
-
const firstLook = await this.resolveAttachment(entry, stillOverride);
|
|
9506
|
-
const
|
|
9507
|
-
const still = firstLook !== null || entry.payload.media === "none" ? firstLook :
|
|
9306
|
+
const firstLook = await this.resolveAttachment(entry, stillOverride, "any");
|
|
9307
|
+
const footagePending = this.renderFootage(entry);
|
|
9308
|
+
const still = firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry, stillOverride);
|
|
9309
|
+
const footage = await footagePending;
|
|
9508
9310
|
if (still !== null) {
|
|
9509
9311
|
const zoneIds = zoneIdsWanted;
|
|
9510
9312
|
if (zoneIds !== void 0 && zoneIds.length > 0) {
|
|
@@ -9610,20 +9412,27 @@ var NcDispatcher = class {
|
|
|
9610
9412
|
/**
|
|
9611
9413
|
* Poll the still ladder for {@link NC_STILL_WAIT_MS}, then give up.
|
|
9612
9414
|
*
|
|
9613
|
-
*
|
|
9614
|
-
*
|
|
9615
|
-
*
|
|
9616
|
-
*
|
|
9617
|
-
*
|
|
9415
|
+
* Runs for EVERY rule now, footage or not, and concurrently with the render:
|
|
9416
|
+
* the still's deadline is its own. Track media lands 1.5–5.5 s after the
|
|
9417
|
+
* trigger, so a notification built at the instant of the trigger routinely
|
|
9418
|
+
* resolves nothing and this is what recovers it.
|
|
9419
|
+
*
|
|
9420
|
+
* Each attempt asks for a frame from the trigger instant and nothing else, so
|
|
9421
|
+
* a poll that has not found one keeps waiting rather than settling for a
|
|
9422
|
+
* later frame that happens to be ranked higher. The give-up pass is the one
|
|
9423
|
+
* place a later frame is accepted, and it says so.
|
|
9424
|
+
*
|
|
9425
|
+
* Bounded and then abandoned — a text-only notification about a person at the
|
|
9426
|
+
* door beats a punctual one nobody received.
|
|
9618
9427
|
*/
|
|
9619
9428
|
async waitForStill(entry, policyOverride) {
|
|
9620
9429
|
const subject = entry.payload.subject;
|
|
9621
|
-
if (this.now() - subject.timestamp > 3e4) return
|
|
9430
|
+
if (this.now() - subject.timestamp > 3e4) return this.resolveAttachment(entry, policyOverride, "any");
|
|
9622
9431
|
if (subject.eventId === void 0 && subject.trackId === void 0) return null;
|
|
9623
9432
|
const attempts = Math.floor(NC_STILL_WAIT_MS / 500);
|
|
9624
9433
|
for (let i = 0; i < attempts; i += 1) {
|
|
9625
9434
|
await this.sleep(500);
|
|
9626
|
-
const still = await this.resolveAttachment(entry, policyOverride);
|
|
9435
|
+
const still = await this.resolveAttachment(entry, policyOverride, "trigger-only");
|
|
9627
9436
|
if (still !== null) {
|
|
9628
9437
|
this.deps.logger.info("the still landed during the bounded wait", {
|
|
9629
9438
|
tags: { deviceId: entry.payload.subject.deviceId },
|
|
@@ -9637,7 +9446,7 @@ var NcDispatcher = class {
|
|
|
9637
9446
|
return still;
|
|
9638
9447
|
}
|
|
9639
9448
|
}
|
|
9640
|
-
return
|
|
9449
|
+
return this.resolveAttachment(entry, policyOverride, "any");
|
|
9641
9450
|
}
|
|
9642
9451
|
/** Crop a JPEG to the padded bbox of the given zones (normalized polygons →
|
|
9643
9452
|
* pixel rect via sharp metadata). Null on any failure — caller falls back
|
|
@@ -9728,7 +9537,86 @@ var NcDispatcher = class {
|
|
|
9728
9537
|
bytes.set(composed.bytes);
|
|
9729
9538
|
return bytes;
|
|
9730
9539
|
}
|
|
9731
|
-
|
|
9540
|
+
/**
|
|
9541
|
+
* Drop the candidate stills that show the subject OUTSIDE the zone that fired
|
|
9542
|
+
* the rule. A filter, never a re-rank: the ladder still decides which kind of
|
|
9543
|
+
* picture the rule gets, among the frames that are about the right thing.
|
|
9544
|
+
*
|
|
9545
|
+
* Every unanswerable case returns the list untouched — no frozen zones, no
|
|
9546
|
+
* track to trail, no dep wired, no polygon resolved, an empty trail, or a
|
|
9547
|
+
* throw. And when nothing qualifies, the full list comes back: a real picture
|
|
9548
|
+
* beats no picture (the same rule D212 settled for the best shot).
|
|
9549
|
+
*/
|
|
9550
|
+
async narrowToTriggerZone(entry, ordered) {
|
|
9551
|
+
const zoneIds = entry.payload.triggerZoneIds;
|
|
9552
|
+
const trackId = entry.payload.subject.trackId;
|
|
9553
|
+
const getTrail = this.deps.getSubjectTrail;
|
|
9554
|
+
const deviceId = entry.payload.subject.deviceId;
|
|
9555
|
+
if (zoneIds === void 0 || zoneIds.length === 0 || trackId === void 0 || getTrail === void 0 || ordered.length < 2) return ordered;
|
|
9556
|
+
try {
|
|
9557
|
+
const [polygons, trail] = await Promise.all([this.deps.getZonePolygons?.(deviceId, zoneIds) ?? Promise.resolve([]), getTrail(deviceId, trackId)]);
|
|
9558
|
+
const kept = selectTriggerZoneCandidates({
|
|
9559
|
+
candidates: ordered,
|
|
9560
|
+
trail,
|
|
9561
|
+
polygons,
|
|
9562
|
+
triggerAt: entry.payload.subject.timestamp
|
|
9563
|
+
});
|
|
9564
|
+
if (kept.length === ordered.length) return kept;
|
|
9565
|
+
this.deps.logger.info("still narrowed to the zone that fired the rule", {
|
|
9566
|
+
tags: { deviceId },
|
|
9567
|
+
meta: {
|
|
9568
|
+
ruleId: entry.ruleId,
|
|
9569
|
+
trackId,
|
|
9570
|
+
zoneIds: zoneIds.join(","),
|
|
9571
|
+
was: ordered[0]?.kind ?? null,
|
|
9572
|
+
wasOffsetMs: ordered[0] !== void 0 ? ordered[0].timestamp - entry.payload.subject.timestamp : null,
|
|
9573
|
+
now: kept[0]?.kind ?? null,
|
|
9574
|
+
nowOffsetMs: kept[0] !== void 0 ? kept[0].timestamp - entry.payload.subject.timestamp : null,
|
|
9575
|
+
dropped: ordered.length - kept.length
|
|
9576
|
+
}
|
|
9577
|
+
});
|
|
9578
|
+
return kept;
|
|
9579
|
+
} catch (err) {
|
|
9580
|
+
this.deps.logger.debug("trigger-zone still narrowing failed — shipping the plain ladder", {
|
|
9581
|
+
tags: { deviceId },
|
|
9582
|
+
meta: {
|
|
9583
|
+
ruleId: entry.ruleId,
|
|
9584
|
+
error: String(err)
|
|
9585
|
+
}
|
|
9586
|
+
});
|
|
9587
|
+
return ordered;
|
|
9588
|
+
}
|
|
9589
|
+
}
|
|
9590
|
+
/**
|
|
9591
|
+
* Keep only the stills that are ABOUT the trigger instant.
|
|
9592
|
+
*
|
|
9593
|
+
* Applies to `delivery: 'immediate'` and nothing else: a track-end rule is
|
|
9594
|
+
* legitimately about the whole track, and a system event has no instant of
|
|
9595
|
+
* its own to be near. Under `any` the list is never emptied — the best
|
|
9596
|
+
* out-of-band frame ships instead, and the degrade is REPORTED, because
|
|
9597
|
+
* "the picture is from nine seconds later" is the difference between a
|
|
9598
|
+
* notification that is late and one that is about something else.
|
|
9599
|
+
*/
|
|
9600
|
+
narrowToTriggerInstant(entry, ordered, freshness) {
|
|
9601
|
+
if (entry.payload.delivery !== "immediate" || ordered.length === 0) return ordered;
|
|
9602
|
+
const triggerAt = entry.payload.subject.timestamp;
|
|
9603
|
+
const inBand = ordered.filter((file) => Math.abs(file.timestamp - triggerAt) <= NC_TRIGGER_STILL_MAX_AGE_MS);
|
|
9604
|
+
if (inBand.length > 0 || freshness === "trigger-only") return inBand;
|
|
9605
|
+
const chosen = ordered[0];
|
|
9606
|
+
if (chosen !== void 0) this.deps.logger.info("no still from the trigger instant — shipping a later frame", {
|
|
9607
|
+
tags: { deviceId: entry.payload.subject.deviceId },
|
|
9608
|
+
meta: {
|
|
9609
|
+
ruleId: entry.ruleId,
|
|
9610
|
+
recordId: entry.recordId,
|
|
9611
|
+
kind: chosen.kind,
|
|
9612
|
+
offsetMs: chosen.timestamp - triggerAt,
|
|
9613
|
+
boundMs: NC_TRIGGER_STILL_MAX_AGE_MS,
|
|
9614
|
+
waitedMs: NC_STILL_WAIT_MS
|
|
9615
|
+
}
|
|
9616
|
+
});
|
|
9617
|
+
return ordered;
|
|
9618
|
+
}
|
|
9619
|
+
async resolveAttachment(entry, policyOverride, freshness = "any") {
|
|
9732
9620
|
const policy = policyOverride ?? entry.payload.media;
|
|
9733
9621
|
if (policy === "none") return null;
|
|
9734
9622
|
const frame = policyOverride === void 0 ? entry.payload.mediaFrame : void 0;
|
|
@@ -9748,9 +9636,12 @@ var NcDispatcher = class {
|
|
|
9748
9636
|
const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
|
|
9749
9637
|
if (files.length === 0) continue;
|
|
9750
9638
|
const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
|
|
9751
|
-
|
|
9752
|
-
|
|
9753
|
-
|
|
9639
|
+
const ordered = [];
|
|
9640
|
+
for (const kind of preference) for (const file of files) if (file.kind === kind) ordered.push(file);
|
|
9641
|
+
const inBand = this.narrowToTriggerInstant(entry, ordered, freshness);
|
|
9642
|
+
if (inBand.length === 0) continue;
|
|
9643
|
+
const shortlist = await this.narrowToTriggerZone(entry, inBand);
|
|
9644
|
+
for (const file of shortlist) {
|
|
9754
9645
|
const raw = Buffer.from(file.base64, "base64");
|
|
9755
9646
|
if (raw.byteLength === 0) continue;
|
|
9756
9647
|
const bytes = new Uint8Array(raw.byteLength);
|
|
@@ -11099,268 +10990,6 @@ function rowToRecord(id, data) {
|
|
|
11099
10990
|
sealed: data["sealed"] === true
|
|
11100
10991
|
};
|
|
11101
10992
|
}
|
|
11102
|
-
//#endregion
|
|
11103
|
-
//#region src/notification-center/liveness-ledger.ts
|
|
11104
|
-
/**
|
|
11105
|
-
* @durable class=ledger owner=notification-center
|
|
11106
|
-
* write="a liveness state TRANSITION the centre accepted (seed or flip); a no-flip writes nothing"
|
|
11107
|
-
* retention="none, deliberately — one row per device plus one per node, bounded by the installation. NOT pruned against the device directory: a prune is work destroyed on a fallible read (D49/D130)."
|
|
11108
|
-
*/
|
|
11109
|
-
var NC_LIVENESS_COLLECTION = "notification-center:liveness";
|
|
11110
|
-
var NC_LIVENESS_COLUMNS = [
|
|
11111
|
-
(
|
|
11112
|
-
/** The subject the intake minted (`device:617`, `node:agent-1`) — one row
|
|
11113
|
-
* each, so the two families can never collide on a key. */
|
|
11114
|
-
{
|
|
11115
|
-
name: "subject",
|
|
11116
|
-
type: "TEXT",
|
|
11117
|
-
primaryKey: true,
|
|
11118
|
-
notNull: true
|
|
11119
|
-
}),
|
|
11120
|
-
(
|
|
11121
|
-
/** `online` | `offline` — the STATE, not the kind, so a legacy `camera-*`
|
|
11122
|
-
* row and a `device-*` event describing the same fact compare equal. */
|
|
11123
|
-
{
|
|
11124
|
-
name: "state",
|
|
11125
|
-
type: "TEXT",
|
|
11126
|
-
notNull: true
|
|
11127
|
-
}),
|
|
11128
|
-
(
|
|
11129
|
-
/** The kind that last wrote the row. Telemetry: it makes the table readable
|
|
11130
|
-
* when someone asks "why did 617 not notify". */
|
|
11131
|
-
{
|
|
11132
|
-
name: "kind",
|
|
11133
|
-
type: "TEXT",
|
|
11134
|
-
notNull: true
|
|
11135
|
-
}),
|
|
11136
|
-
(
|
|
11137
|
-
/** Absent on a node row. Indexed — every question about a device is asked
|
|
11138
|
-
* per-device. */
|
|
11139
|
-
{
|
|
11140
|
-
name: "deviceId",
|
|
11141
|
-
type: "INTEGER"
|
|
11142
|
-
}),
|
|
11143
|
-
{
|
|
11144
|
-
name: "updatedAt",
|
|
11145
|
-
type: "INTEGER",
|
|
11146
|
-
notNull: true
|
|
11147
|
-
}
|
|
11148
|
-
];
|
|
11149
|
-
var NC_LIVENESS_INDEXES = [{
|
|
11150
|
-
name: "idx_nc_liveness_device",
|
|
11151
|
-
columns: ["deviceId"]
|
|
11152
|
-
}];
|
|
11153
|
-
/**
|
|
11154
|
-
* The kinds the ledger governs — see the module docblock for why the stream
|
|
11155
|
-
* and switch families are absent.
|
|
11156
|
-
*/
|
|
11157
|
-
var NC_LEDGERED_KINDS = new Set([
|
|
11158
|
-
"device-online",
|
|
11159
|
-
"device-offline",
|
|
11160
|
-
"node-online",
|
|
11161
|
-
"node-offline",
|
|
11162
|
-
"camera-online",
|
|
11163
|
-
"camera-offline"
|
|
11164
|
-
]);
|
|
11165
|
-
/**
|
|
11166
|
-
* Reduce a system-event subject to a ledger observation, or `null` when the
|
|
11167
|
-
* kind is not one the ledger governs.
|
|
11168
|
-
*
|
|
11169
|
-
* The ONE place that decides which kinds are gated: a caller that branched on
|
|
11170
|
-
* a `kind.startsWith('device-')` prefix would silently pull the operator's
|
|
11171
|
-
* disable switch into a liveness ledger, which is the mistake the docblock
|
|
11172
|
-
* spells out.
|
|
11173
|
-
*/
|
|
11174
|
-
function ledgeredLiveness(system) {
|
|
11175
|
-
if (!NC_LEDGERED_KINDS.has(system.kind)) return null;
|
|
11176
|
-
const state = system.kind.endsWith("-offline") ? "offline" : "online";
|
|
11177
|
-
return {
|
|
11178
|
-
subject: system.subject,
|
|
11179
|
-
kind: system.kind,
|
|
11180
|
-
state,
|
|
11181
|
-
...system.deviceId !== void 0 ? { deviceId: system.deviceId } : {}
|
|
11182
|
-
};
|
|
11183
|
-
}
|
|
11184
|
-
/**
|
|
11185
|
-
* The ledger's mechanics, declared once. Everything policy-shaped is right
|
|
11186
|
-
* here in this object: what the KEY is, what the persisted row looks like, and
|
|
11187
|
-
* — the only decision the primitive cannot make — WHAT COUNTS AS THE SAME
|
|
11188
|
-
* FACT. For liveness the fact is the STATE, so a legacy `camera-online` row
|
|
11189
|
-
* and a `device-online` event describing it compare equal, and a repeat never
|
|
11190
|
-
* moves `updatedAt`.
|
|
11191
|
-
*
|
|
11192
|
-
* The reseed is uncapped in practice: the row set is bounded by the
|
|
11193
|
-
* installation — one row per device plus one per node — so the primitive's
|
|
11194
|
-
* default ceiling still bounds a pathological read. Deliberately NOT pruned
|
|
11195
|
-
* against the device directory: a prune is work DESTROYED on a fallible read
|
|
11196
|
-
* (D49), and a directory that answered empty once would wipe the ledger and
|
|
11197
|
-
* re-arm the very flood this file exists to prevent. A removed device leaves
|
|
11198
|
-
* exactly one row behind, and a re-added one re-seeds correctly.
|
|
11199
|
-
*/
|
|
11200
|
-
var NC_LIVENESS_SPEC = {
|
|
11201
|
-
collection: NC_LIVENESS_COLLECTION,
|
|
11202
|
-
columns: NC_LIVENESS_COLUMNS,
|
|
11203
|
-
indexes: NC_LIVENESS_INDEXES,
|
|
11204
|
-
writeMode: "write-behind",
|
|
11205
|
-
keyOf: (row) => row.subject,
|
|
11206
|
-
toValue: rowToValue$1,
|
|
11207
|
-
fromRecord: recordToRow$2,
|
|
11208
|
-
equalFact: (held, incoming) => held.state === incoming.state,
|
|
11209
|
-
deviceIdOf: (row) => row.deviceId
|
|
11210
|
-
};
|
|
11211
|
-
var NcLivenessLedger = class {
|
|
11212
|
-
ledger;
|
|
11213
|
-
constructor(deps) {
|
|
11214
|
-
this.ledger = new DurableLedger({
|
|
11215
|
-
spec: NC_LIVENESS_SPEC,
|
|
11216
|
-
store: deps.store,
|
|
11217
|
-
logger: deps.logger
|
|
11218
|
-
});
|
|
11219
|
-
}
|
|
11220
|
-
static declare(store) {
|
|
11221
|
-
return DurableLedger.declare(store, NC_LIVENESS_SPEC);
|
|
11222
|
-
}
|
|
11223
|
-
/**
|
|
11224
|
-
* Boot reseed — the whole point of the file. Returns the row count so the
|
|
11225
|
-
* caller can say out loud how much memory it recovered; "loaded 0" after a
|
|
11226
|
-
* container recreate is the one line that would have explained tonight.
|
|
11227
|
-
*
|
|
11228
|
-
* A failed load KEEPS whatever is already mirrored (the primitive's contract
|
|
11229
|
-
* rule 2) rather than clearing it: dropping the memory is what caused the
|
|
11230
|
-
* flood, so no error path may do it on purpose.
|
|
11231
|
-
*/
|
|
11232
|
-
async load() {
|
|
11233
|
-
return (await this.ledger.load()).length;
|
|
11234
|
-
}
|
|
11235
|
-
/**
|
|
11236
|
-
* Boot seed from the DEVICE DIRECTORY — the second half of the reseed, and
|
|
11237
|
-
* the fix for the outage this ledger used to swallow.
|
|
11238
|
-
*
|
|
11239
|
-
* ## The hole
|
|
11240
|
-
*
|
|
11241
|
-
* {@link load} recovers what the ledger itself accepted. A device that has
|
|
11242
|
-
* never produced a liveness event since the ledger was created has no row at
|
|
11243
|
-
* all, so its FIRST observation ever seeds silently — and if that first
|
|
11244
|
-
* observation is the camera DYING, the outage is thrown away. Live cost,
|
|
11245
|
-
* 2026-08-13 21:36 CEST: the operator unplugged four cameras and received two
|
|
11246
|
-
* notifications. `device:584` and `device:587` had never spoken; their
|
|
11247
|
-
* unplugging was recorded and never announced. A camera that is quiet until
|
|
11248
|
-
* it dies is a camera whose death is silent, indefinitely.
|
|
11249
|
-
*
|
|
11250
|
-
* ## Why only the ONLINE ones
|
|
11251
|
-
*
|
|
11252
|
-
* The directory is the device manager's own `online` flag (D132 §3: the
|
|
11253
|
-
* authority seeds the mirror at boot and stays the authority). Seeding only
|
|
11254
|
-
* the devices it calls ONLINE is what makes this change one-directional:
|
|
11255
|
-
*
|
|
11256
|
-
* - a row seeded `online` can only ever turn a later `device.offline` into a
|
|
11257
|
-
* notification — a true sentence about a device that just went away;
|
|
11258
|
-
* - it can NEVER turn a `device.online` into one, and that is the only shape
|
|
11259
|
-
* the 2026-08-13 notification flood (D130) ever had.
|
|
11260
|
-
*
|
|
11261
|
-
* A device the directory calls offline — which is also what a cold or
|
|
11262
|
-
* still-settling directory would say — gets no row and keeps today's
|
|
11263
|
-
* behaviour exactly. Failing toward discard, unchanged.
|
|
11264
|
-
*
|
|
11265
|
-
* ## D49
|
|
11266
|
-
*
|
|
11267
|
-
* An EXISTING row is never overwritten. The ledger is the authority for what
|
|
11268
|
-
* it accepted; the directory is a mirror refreshed on a tick and may be
|
|
11269
|
-
* stale. A stale `online` overwriting an accepted `offline` would make the
|
|
11270
|
-
* camera's real recovery a no-flip and lose it. A failed directory read
|
|
11271
|
-
* simply yields an empty list here and seeds nothing.
|
|
11272
|
-
*
|
|
11273
|
-
* ## Staged, not persisted
|
|
11274
|
-
*
|
|
11275
|
-
* The seed is DERIVED from an authority that is read again on every boot, so
|
|
11276
|
-
* losing it costs nothing — the next boot re-derives it. `stage` therefore
|
|
11277
|
-
* advances the mirror without a write, which matters on this hub: the
|
|
11278
|
-
* pipeline-analytics runner respawns several times an hour, and persisting
|
|
11279
|
-
* ~500 re-derivable rows on each respawn would offer thousands of pointless
|
|
11280
|
-
* commits a day to the checkpoint lottery (D96). The first real
|
|
11281
|
-
* {@link observe} flip persists through the ordinary write-behind path.
|
|
11282
|
-
*
|
|
11283
|
-
* Returns how many rows were seeded, so the caller can say it out loud.
|
|
11284
|
-
*/
|
|
11285
|
-
seedFromDirectory(identities, now) {
|
|
11286
|
-
let seeded = 0;
|
|
11287
|
-
for (const identity of identities) {
|
|
11288
|
-
if (identity.online !== true) continue;
|
|
11289
|
-
const subject = `device:${identity.deviceId}`;
|
|
11290
|
-
if (this.ledger.has(subject)) continue;
|
|
11291
|
-
this.ledger.stage({
|
|
11292
|
-
subject,
|
|
11293
|
-
state: "online",
|
|
11294
|
-
kind: "device-online",
|
|
11295
|
-
deviceId: identity.deviceId,
|
|
11296
|
-
updatedAt: now
|
|
11297
|
-
});
|
|
11298
|
-
seeded += 1;
|
|
11299
|
-
}
|
|
11300
|
-
return seeded;
|
|
11301
|
-
}
|
|
11302
|
-
/** The in-RAM mirror, subject-ordered so two readers agree. */
|
|
11303
|
-
snapshot() {
|
|
11304
|
-
return this.ledger.snapshot().toSorted((a, b) => a.subject.localeCompare(b.subject));
|
|
11305
|
-
}
|
|
11306
|
-
/** The state the ledger currently accepts for a subject, if any. */
|
|
11307
|
-
stateOf(subject) {
|
|
11308
|
-
return this.ledger.get(subject)?.state;
|
|
11309
|
-
}
|
|
11310
|
-
/**
|
|
11311
|
-
* Judge ONE observation and advance the ledger.
|
|
11312
|
-
*
|
|
11313
|
-
* Synchronous on purpose: the verdict is a function of the in-RAM mirror
|
|
11314
|
-
* alone, so an event can never be gated on an I/O that might fail (D49). The
|
|
11315
|
-
* durable write is kicked off behind it and its failure changes no verdict.
|
|
11316
|
-
*
|
|
11317
|
-
* `no-flip` leaves the row untouched — including its `updatedAt`, which
|
|
11318
|
-
* therefore means "when this subject last CHANGED", not "when it last
|
|
11319
|
-
* spoke". That is the timestamp anyone reading the table wants.
|
|
11320
|
-
*/
|
|
11321
|
-
observe(observation, now) {
|
|
11322
|
-
return this.ledger.observe({
|
|
11323
|
-
subject: observation.subject,
|
|
11324
|
-
state: observation.state,
|
|
11325
|
-
kind: observation.kind,
|
|
11326
|
-
...observation.deviceId !== void 0 ? { deviceId: observation.deviceId } : {},
|
|
11327
|
-
updatedAt: now
|
|
11328
|
-
});
|
|
11329
|
-
}
|
|
11330
|
-
};
|
|
11331
|
-
/** The persisted column map (the `subject` PK is passed separately). */
|
|
11332
|
-
function rowToValue$1(row) {
|
|
11333
|
-
return {
|
|
11334
|
-
state: row.state,
|
|
11335
|
-
kind: row.kind,
|
|
11336
|
-
...row.deviceId !== void 0 ? { deviceId: row.deviceId } : {},
|
|
11337
|
-
updatedAt: row.updatedAt
|
|
11338
|
-
};
|
|
11339
|
-
}
|
|
11340
|
-
/**
|
|
11341
|
-
* Structurally validate one persisted record. A row whose state is not one of
|
|
11342
|
-
* the two known values is `null` — skipped, so the subject re-seeds cold
|
|
11343
|
-
* (silent) rather than hydrating a state nothing can ever equal, which would
|
|
11344
|
-
* make every future event a phantom flip.
|
|
11345
|
-
*/
|
|
11346
|
-
function recordToRow$2(subject, data) {
|
|
11347
|
-
if (subject.length === 0) return null;
|
|
11348
|
-
const state = data["state"];
|
|
11349
|
-
if (state !== "online" && state !== "offline") return null;
|
|
11350
|
-
const kind = data["kind"];
|
|
11351
|
-
if (typeof kind !== "string" || kind.length === 0) return null;
|
|
11352
|
-
const updatedAt = Number(data["updatedAt"]);
|
|
11353
|
-
if (!Number.isFinite(updatedAt)) return null;
|
|
11354
|
-
const rawDeviceId = data["deviceId"];
|
|
11355
|
-
const deviceId = typeof rawDeviceId === "number" && Number.isFinite(rawDeviceId) ? rawDeviceId : void 0;
|
|
11356
|
-
return {
|
|
11357
|
-
subject,
|
|
11358
|
-
state,
|
|
11359
|
-
kind,
|
|
11360
|
-
...deviceId !== void 0 ? { deviceId } : {},
|
|
11361
|
-
updatedAt
|
|
11362
|
-
};
|
|
11363
|
-
}
|
|
11364
10993
|
function isDeviceLivenessKind(kind) {
|
|
11365
10994
|
return kind === "device-online" || kind === "device-offline" || kind === "camera-online" || kind === "camera-offline";
|
|
11366
10995
|
}
|
|
@@ -11423,6 +11052,268 @@ var DeviceLivenessHoldoff = class {
|
|
|
11423
11052
|
}
|
|
11424
11053
|
};
|
|
11425
11054
|
//#endregion
|
|
11055
|
+
//#region src/notification-center/liveness-ledger.ts
|
|
11056
|
+
/**
|
|
11057
|
+
* @durable class=ledger owner=notification-center
|
|
11058
|
+
* write="a liveness state TRANSITION the centre accepted (seed or flip); a no-flip writes nothing"
|
|
11059
|
+
* retention="none, deliberately — one row per device plus one per node, bounded by the installation. NOT pruned against the device directory: a prune is work destroyed on a fallible read (D49/D130)."
|
|
11060
|
+
*/
|
|
11061
|
+
var NC_LIVENESS_COLLECTION = "notification-center:liveness";
|
|
11062
|
+
var NC_LIVENESS_COLUMNS = [
|
|
11063
|
+
(
|
|
11064
|
+
/** The subject the intake minted (`device:617`, `node:agent-1`) — one row
|
|
11065
|
+
* each, so the two families can never collide on a key. */
|
|
11066
|
+
{
|
|
11067
|
+
name: "subject",
|
|
11068
|
+
type: "TEXT",
|
|
11069
|
+
primaryKey: true,
|
|
11070
|
+
notNull: true
|
|
11071
|
+
}),
|
|
11072
|
+
(
|
|
11073
|
+
/** `online` | `offline` — the STATE, not the kind, so a legacy `camera-*`
|
|
11074
|
+
* row and a `device-*` event describing the same fact compare equal. */
|
|
11075
|
+
{
|
|
11076
|
+
name: "state",
|
|
11077
|
+
type: "TEXT",
|
|
11078
|
+
notNull: true
|
|
11079
|
+
}),
|
|
11080
|
+
(
|
|
11081
|
+
/** The kind that last wrote the row. Telemetry: it makes the table readable
|
|
11082
|
+
* when someone asks "why did 617 not notify". */
|
|
11083
|
+
{
|
|
11084
|
+
name: "kind",
|
|
11085
|
+
type: "TEXT",
|
|
11086
|
+
notNull: true
|
|
11087
|
+
}),
|
|
11088
|
+
(
|
|
11089
|
+
/** Absent on a node row. Indexed — every question about a device is asked
|
|
11090
|
+
* per-device. */
|
|
11091
|
+
{
|
|
11092
|
+
name: "deviceId",
|
|
11093
|
+
type: "INTEGER"
|
|
11094
|
+
}),
|
|
11095
|
+
{
|
|
11096
|
+
name: "updatedAt",
|
|
11097
|
+
type: "INTEGER",
|
|
11098
|
+
notNull: true
|
|
11099
|
+
}
|
|
11100
|
+
];
|
|
11101
|
+
var NC_LIVENESS_INDEXES = [{
|
|
11102
|
+
name: "idx_nc_liveness_device",
|
|
11103
|
+
columns: ["deviceId"]
|
|
11104
|
+
}];
|
|
11105
|
+
/**
|
|
11106
|
+
* The kinds the ledger governs — see the module docblock for why the stream
|
|
11107
|
+
* and switch families are absent.
|
|
11108
|
+
*/
|
|
11109
|
+
var NC_LEDGERED_KINDS = new Set([
|
|
11110
|
+
"device-online",
|
|
11111
|
+
"device-offline",
|
|
11112
|
+
"node-online",
|
|
11113
|
+
"node-offline",
|
|
11114
|
+
"camera-online",
|
|
11115
|
+
"camera-offline"
|
|
11116
|
+
]);
|
|
11117
|
+
/**
|
|
11118
|
+
* Reduce a system-event subject to a ledger observation, or `null` when the
|
|
11119
|
+
* kind is not one the ledger governs.
|
|
11120
|
+
*
|
|
11121
|
+
* The ONE place that decides which kinds are gated: a caller that branched on
|
|
11122
|
+
* a `kind.startsWith('device-')` prefix would silently pull the operator's
|
|
11123
|
+
* disable switch into a liveness ledger, which is the mistake the docblock
|
|
11124
|
+
* spells out.
|
|
11125
|
+
*/
|
|
11126
|
+
function ledgeredLiveness(system) {
|
|
11127
|
+
if (!NC_LEDGERED_KINDS.has(system.kind)) return null;
|
|
11128
|
+
const state = system.kind.endsWith("-offline") ? "offline" : "online";
|
|
11129
|
+
return {
|
|
11130
|
+
subject: system.subject,
|
|
11131
|
+
kind: system.kind,
|
|
11132
|
+
state,
|
|
11133
|
+
...system.deviceId !== void 0 ? { deviceId: system.deviceId } : {}
|
|
11134
|
+
};
|
|
11135
|
+
}
|
|
11136
|
+
/**
|
|
11137
|
+
* The ledger's mechanics, declared once. Everything policy-shaped is right
|
|
11138
|
+
* here in this object: what the KEY is, what the persisted row looks like, and
|
|
11139
|
+
* — the only decision the primitive cannot make — WHAT COUNTS AS THE SAME
|
|
11140
|
+
* FACT. For liveness the fact is the STATE, so a legacy `camera-online` row
|
|
11141
|
+
* and a `device-online` event describing it compare equal, and a repeat never
|
|
11142
|
+
* moves `updatedAt`.
|
|
11143
|
+
*
|
|
11144
|
+
* The reseed is uncapped in practice: the row set is bounded by the
|
|
11145
|
+
* installation — one row per device plus one per node — so the primitive's
|
|
11146
|
+
* default ceiling still bounds a pathological read. Deliberately NOT pruned
|
|
11147
|
+
* against the device directory: a prune is work DESTROYED on a fallible read
|
|
11148
|
+
* (D49), and a directory that answered empty once would wipe the ledger and
|
|
11149
|
+
* re-arm the very flood this file exists to prevent. A removed device leaves
|
|
11150
|
+
* exactly one row behind, and a re-added one re-seeds correctly.
|
|
11151
|
+
*/
|
|
11152
|
+
var NC_LIVENESS_SPEC = {
|
|
11153
|
+
collection: NC_LIVENESS_COLLECTION,
|
|
11154
|
+
columns: NC_LIVENESS_COLUMNS,
|
|
11155
|
+
indexes: NC_LIVENESS_INDEXES,
|
|
11156
|
+
writeMode: "write-behind",
|
|
11157
|
+
keyOf: (row) => row.subject,
|
|
11158
|
+
toValue: rowToValue$1,
|
|
11159
|
+
fromRecord: recordToRow$2,
|
|
11160
|
+
equalFact: (held, incoming) => held.state === incoming.state,
|
|
11161
|
+
deviceIdOf: (row) => row.deviceId
|
|
11162
|
+
};
|
|
11163
|
+
var NcLivenessLedger = class {
|
|
11164
|
+
ledger;
|
|
11165
|
+
constructor(deps) {
|
|
11166
|
+
this.ledger = new DurableLedger({
|
|
11167
|
+
spec: NC_LIVENESS_SPEC,
|
|
11168
|
+
store: deps.store,
|
|
11169
|
+
logger: deps.logger
|
|
11170
|
+
});
|
|
11171
|
+
}
|
|
11172
|
+
static declare(store) {
|
|
11173
|
+
return DurableLedger.declare(store, NC_LIVENESS_SPEC);
|
|
11174
|
+
}
|
|
11175
|
+
/**
|
|
11176
|
+
* Boot reseed — the whole point of the file. Returns the row count so the
|
|
11177
|
+
* caller can say out loud how much memory it recovered; "loaded 0" after a
|
|
11178
|
+
* container recreate is the one line that would have explained tonight.
|
|
11179
|
+
*
|
|
11180
|
+
* A failed load KEEPS whatever is already mirrored (the primitive's contract
|
|
11181
|
+
* rule 2) rather than clearing it: dropping the memory is what caused the
|
|
11182
|
+
* flood, so no error path may do it on purpose.
|
|
11183
|
+
*/
|
|
11184
|
+
async load() {
|
|
11185
|
+
return (await this.ledger.load()).length;
|
|
11186
|
+
}
|
|
11187
|
+
/**
|
|
11188
|
+
* Boot seed from the DEVICE DIRECTORY — the second half of the reseed, and
|
|
11189
|
+
* the fix for the outage this ledger used to swallow.
|
|
11190
|
+
*
|
|
11191
|
+
* ## The hole
|
|
11192
|
+
*
|
|
11193
|
+
* {@link load} recovers what the ledger itself accepted. A device that has
|
|
11194
|
+
* never produced a liveness event since the ledger was created has no row at
|
|
11195
|
+
* all, so its FIRST observation ever seeds silently — and if that first
|
|
11196
|
+
* observation is the camera DYING, the outage is thrown away. Live cost,
|
|
11197
|
+
* 2026-08-13 21:36 CEST: the operator unplugged four cameras and received two
|
|
11198
|
+
* notifications. `device:584` and `device:587` had never spoken; their
|
|
11199
|
+
* unplugging was recorded and never announced. A camera that is quiet until
|
|
11200
|
+
* it dies is a camera whose death is silent, indefinitely.
|
|
11201
|
+
*
|
|
11202
|
+
* ## Why only the ONLINE ones
|
|
11203
|
+
*
|
|
11204
|
+
* The directory is the device manager's own `online` flag (D132 §3: the
|
|
11205
|
+
* authority seeds the mirror at boot and stays the authority). Seeding only
|
|
11206
|
+
* the devices it calls ONLINE is what makes this change one-directional:
|
|
11207
|
+
*
|
|
11208
|
+
* - a row seeded `online` can only ever turn a later `device.offline` into a
|
|
11209
|
+
* notification — a true sentence about a device that just went away;
|
|
11210
|
+
* - it can NEVER turn a `device.online` into one, and that is the only shape
|
|
11211
|
+
* the 2026-08-13 notification flood (D130) ever had.
|
|
11212
|
+
*
|
|
11213
|
+
* A device the directory calls offline — which is also what a cold or
|
|
11214
|
+
* still-settling directory would say — gets no row and keeps today's
|
|
11215
|
+
* behaviour exactly. Failing toward discard, unchanged.
|
|
11216
|
+
*
|
|
11217
|
+
* ## D49
|
|
11218
|
+
*
|
|
11219
|
+
* An EXISTING row is never overwritten. The ledger is the authority for what
|
|
11220
|
+
* it accepted; the directory is a mirror refreshed on a tick and may be
|
|
11221
|
+
* stale. A stale `online` overwriting an accepted `offline` would make the
|
|
11222
|
+
* camera's real recovery a no-flip and lose it. A failed directory read
|
|
11223
|
+
* simply yields an empty list here and seeds nothing.
|
|
11224
|
+
*
|
|
11225
|
+
* ## Staged, not persisted
|
|
11226
|
+
*
|
|
11227
|
+
* The seed is DERIVED from an authority that is read again on every boot, so
|
|
11228
|
+
* losing it costs nothing — the next boot re-derives it. `stage` therefore
|
|
11229
|
+
* advances the mirror without a write, which matters on this hub: the
|
|
11230
|
+
* pipeline-analytics runner respawns several times an hour, and persisting
|
|
11231
|
+
* ~500 re-derivable rows on each respawn would offer thousands of pointless
|
|
11232
|
+
* commits a day to the checkpoint lottery (D96). The first real
|
|
11233
|
+
* {@link observe} flip persists through the ordinary write-behind path.
|
|
11234
|
+
*
|
|
11235
|
+
* Returns how many rows were seeded, so the caller can say it out loud.
|
|
11236
|
+
*/
|
|
11237
|
+
seedFromDirectory(identities, now) {
|
|
11238
|
+
let seeded = 0;
|
|
11239
|
+
for (const identity of identities) {
|
|
11240
|
+
if (identity.online !== true) continue;
|
|
11241
|
+
const subject = `device:${identity.deviceId}`;
|
|
11242
|
+
if (this.ledger.has(subject)) continue;
|
|
11243
|
+
this.ledger.stage({
|
|
11244
|
+
subject,
|
|
11245
|
+
state: "online",
|
|
11246
|
+
kind: "device-online",
|
|
11247
|
+
deviceId: identity.deviceId,
|
|
11248
|
+
updatedAt: now
|
|
11249
|
+
});
|
|
11250
|
+
seeded += 1;
|
|
11251
|
+
}
|
|
11252
|
+
return seeded;
|
|
11253
|
+
}
|
|
11254
|
+
/** The in-RAM mirror, subject-ordered so two readers agree. */
|
|
11255
|
+
snapshot() {
|
|
11256
|
+
return this.ledger.snapshot().toSorted((a, b) => a.subject.localeCompare(b.subject));
|
|
11257
|
+
}
|
|
11258
|
+
/** The state the ledger currently accepts for a subject, if any. */
|
|
11259
|
+
stateOf(subject) {
|
|
11260
|
+
return this.ledger.get(subject)?.state;
|
|
11261
|
+
}
|
|
11262
|
+
/**
|
|
11263
|
+
* Judge ONE observation and advance the ledger.
|
|
11264
|
+
*
|
|
11265
|
+
* Synchronous on purpose: the verdict is a function of the in-RAM mirror
|
|
11266
|
+
* alone, so an event can never be gated on an I/O that might fail (D49). The
|
|
11267
|
+
* durable write is kicked off behind it and its failure changes no verdict.
|
|
11268
|
+
*
|
|
11269
|
+
* `no-flip` leaves the row untouched — including its `updatedAt`, which
|
|
11270
|
+
* therefore means "when this subject last CHANGED", not "when it last
|
|
11271
|
+
* spoke". That is the timestamp anyone reading the table wants.
|
|
11272
|
+
*/
|
|
11273
|
+
observe(observation, now) {
|
|
11274
|
+
return this.ledger.observe({
|
|
11275
|
+
subject: observation.subject,
|
|
11276
|
+
state: observation.state,
|
|
11277
|
+
kind: observation.kind,
|
|
11278
|
+
...observation.deviceId !== void 0 ? { deviceId: observation.deviceId } : {},
|
|
11279
|
+
updatedAt: now
|
|
11280
|
+
});
|
|
11281
|
+
}
|
|
11282
|
+
};
|
|
11283
|
+
/** The persisted column map (the `subject` PK is passed separately). */
|
|
11284
|
+
function rowToValue$1(row) {
|
|
11285
|
+
return {
|
|
11286
|
+
state: row.state,
|
|
11287
|
+
kind: row.kind,
|
|
11288
|
+
...row.deviceId !== void 0 ? { deviceId: row.deviceId } : {},
|
|
11289
|
+
updatedAt: row.updatedAt
|
|
11290
|
+
};
|
|
11291
|
+
}
|
|
11292
|
+
/**
|
|
11293
|
+
* Structurally validate one persisted record. A row whose state is not one of
|
|
11294
|
+
* the two known values is `null` — skipped, so the subject re-seeds cold
|
|
11295
|
+
* (silent) rather than hydrating a state nothing can ever equal, which would
|
|
11296
|
+
* make every future event a phantom flip.
|
|
11297
|
+
*/
|
|
11298
|
+
function recordToRow$2(subject, data) {
|
|
11299
|
+
if (subject.length === 0) return null;
|
|
11300
|
+
const state = data["state"];
|
|
11301
|
+
if (state !== "online" && state !== "offline") return null;
|
|
11302
|
+
const kind = data["kind"];
|
|
11303
|
+
if (typeof kind !== "string" || kind.length === 0) return null;
|
|
11304
|
+
const updatedAt = Number(data["updatedAt"]);
|
|
11305
|
+
if (!Number.isFinite(updatedAt)) return null;
|
|
11306
|
+
const rawDeviceId = data["deviceId"];
|
|
11307
|
+
const deviceId = typeof rawDeviceId === "number" && Number.isFinite(rawDeviceId) ? rawDeviceId : void 0;
|
|
11308
|
+
return {
|
|
11309
|
+
subject,
|
|
11310
|
+
state,
|
|
11311
|
+
kind,
|
|
11312
|
+
...deviceId !== void 0 ? { deviceId } : {},
|
|
11313
|
+
updatedAt
|
|
11314
|
+
};
|
|
11315
|
+
}
|
|
11316
|
+
//#endregion
|
|
11426
11317
|
//#region src/notification-center/occupancy-watcher.ts
|
|
11427
11318
|
/** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
|
|
11428
11319
|
var FRAME_SCOPE = "@frame";
|
|
@@ -13789,6 +13680,279 @@ var NcSnoozeStore = class {
|
|
|
13789
13680
|
return parsed.success ? parsed.data : null;
|
|
13790
13681
|
}
|
|
13791
13682
|
};
|
|
13683
|
+
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
13684
|
+
var FULL_FRAME_QUALITY = 80;
|
|
13685
|
+
/**
|
|
13686
|
+
* Downscale an already-encoded JPEG full frame to FIT WITHIN
|
|
13687
|
+
* {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
|
|
13688
|
+
* ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
|
|
13689
|
+
* box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
|
|
13690
|
+
* track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
|
|
13691
|
+
* at night) is never stored or served — the privacy fix moved to CAPTURE time.
|
|
13692
|
+
*/
|
|
13693
|
+
async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
13694
|
+
return (0, sharp.default)(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
|
|
13695
|
+
fit: "inside",
|
|
13696
|
+
withoutEnlargement: true
|
|
13697
|
+
}).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
|
|
13698
|
+
}
|
|
13699
|
+
//#endregion
|
|
13700
|
+
//#region src/notification-center/still-shelf.ts
|
|
13701
|
+
/**
|
|
13702
|
+
* The still shelf — the PHOTOGRAPH a notification carries when nothing it can
|
|
13703
|
+
* name owns a frame.
|
|
13704
|
+
*
|
|
13705
|
+
* ## Why a trigger needs one at all
|
|
13706
|
+
*
|
|
13707
|
+
* The attachment ladder resolves media by OWNER, and most triggers have one: an
|
|
13708
|
+
* object or package event owns its crops, a closed track owns its best shot, a
|
|
13709
|
+
* doorbell press owns the marker track the same press projected
|
|
13710
|
+
* (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
|
|
13711
|
+
* counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
|
|
13712
|
+
* same reason in both cases — the subject is an ABSENCE:
|
|
13713
|
+
*
|
|
13714
|
+
* - an **audio** match: nothing was boxed, nothing was tracked, and —
|
|
13715
|
+
* deliberately — nothing is persisted at all. A confirmed window is a claim
|
|
13716
|
+
* about sound that has already stopped, and `event-intake.ts` states why
|
|
13717
|
+
* replaying it later would be wrong.
|
|
13718
|
+
* - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
|
|
13719
|
+
* whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
|
|
13720
|
+
* nobody and the ladder logs `no still could be resolved … owners=[]`.
|
|
13721
|
+
*
|
|
13722
|
+
* So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
|
|
13723
|
+
* of the trigger. Not of the sound, not of the object that left — of what the
|
|
13724
|
+
* camera can see now that it happened. For a freed parking space that is
|
|
13725
|
+
* exactly the answer the operator wants: the space, empty.
|
|
13726
|
+
*
|
|
13727
|
+
* **One shelf, not two.** The mechanism is identical down to the reuse window,
|
|
13728
|
+
* and the only thing that differs between the two triggers is the GATE deciding
|
|
13729
|
+
* that a photograph is owed at all — which belongs at the trigger site, where
|
|
13730
|
+
* the rules are, and not here. A twin module would be a second derivation of
|
|
13731
|
+
* one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
|
|
13732
|
+
* can say which absence they are about.
|
|
13733
|
+
*
|
|
13734
|
+
* ## Three properties, and each one is a decision
|
|
13735
|
+
*
|
|
13736
|
+
* **It is not a record.** The bytes live here, in RAM, under an owner id and a
|
|
13737
|
+
* TTL that covers the outbox's whole retry horizon — and nowhere else. The
|
|
13738
|
+
* alternative was the doorbell's: materialise a synthetic marker track through
|
|
13739
|
+
* `SyntheticTrackMaterializer` and let the notification name it. That would put
|
|
13740
|
+
* a durable Track on the camera's timeline for every confirmed window and every
|
|
13741
|
+
* emptied zone, feeding the digest's `listTracks`, retention, and the audio-
|
|
13742
|
+
* marker feature's own operator ceilings (`audio-marker-projector.ts` exists
|
|
13743
|
+
* precisely to bound how many audio markers a camera may emit). A notification
|
|
13744
|
+
* must not manufacture timeline history as a side effect of wanting a picture.
|
|
13745
|
+
*
|
|
13746
|
+
* **The capture STARTS immediately and is never awaited.** The subject is
|
|
13747
|
+
* transient — a scream is over before a snapshot round-trip completes, and a
|
|
13748
|
+
* freed space is about to be taken by the next car — so the fetch is kicked off
|
|
13749
|
+
* at the trigger, before the rule evaluation runs, and the owner id is minted
|
|
13750
|
+
* synchronously so the outbox row can name it. The bytes land while the row
|
|
13751
|
+
* waits in the queue, and the dispatcher's existing bounded still-wait (or the
|
|
13752
|
+
* pause its own footage render already costs) picks them up. A camera that
|
|
13753
|
+
* never answers costs the picture and never the notification.
|
|
13754
|
+
*
|
|
13755
|
+
* **Two triggers seconds apart share ONE capture.** A barking dog confirms
|
|
13756
|
+
* repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
|
|
13757
|
+
* rule's cooldown is its only brake, and the cooldown is applied AFTER this.
|
|
13758
|
+
* Without a reuse window this would photograph a camera at whatever rate the
|
|
13759
|
+
* sound happens to occur. Each trigger still gets its OWN owner id, so two
|
|
13760
|
+
* outbox rows are never mistaken for one subject; they merely point at the same
|
|
13761
|
+
* frame, which is the truth — the scene did not change in ten seconds. The
|
|
13762
|
+
* window is per CAMERA and trigger-agnostic for the same reason: a sound and an
|
|
13763
|
+
* emptied zone ten seconds apart are two claims about one scene.
|
|
13764
|
+
*
|
|
13765
|
+
* Nothing here is silent: a capture that lands and a camera that refuses each
|
|
13766
|
+
* emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
|
|
13767
|
+
* and 615 not" is the only form that question is ever asked in.
|
|
13768
|
+
*/
|
|
13769
|
+
/**
|
|
13770
|
+
* The owner-id namespace. It is what routes a lookup here instead of to the
|
|
13771
|
+
* media store, and it is the reason the dispatcher needs no audio or occupancy
|
|
13772
|
+
* branch — `getMediaForOwner` answers for all of them under one signature.
|
|
13773
|
+
*/
|
|
13774
|
+
var NC_STILL_SHELF_PREFIX = "nc-still:";
|
|
13775
|
+
/** True for an owner id this shelf minted. */
|
|
13776
|
+
function isStillShelfId(id) {
|
|
13777
|
+
return id.startsWith(NC_STILL_SHELF_PREFIX);
|
|
13778
|
+
}
|
|
13779
|
+
/**
|
|
13780
|
+
* How long the bytes are held.
|
|
13781
|
+
*
|
|
13782
|
+
* The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
|
|
13783
|
+
* ten minutes; fifteen covers that with room for a slow drain. Past it the row
|
|
13784
|
+
* ships text-only, which is the correct degradation for a photograph of a scene
|
|
13785
|
+
* that is a quarter of an hour stale anyway.
|
|
13786
|
+
*/
|
|
13787
|
+
var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
|
|
13788
|
+
/** A hanging snapshot cap must not pin a capture slot forever. */
|
|
13789
|
+
var SNAPSHOT_TIMEOUT_MS = 8e3;
|
|
13790
|
+
/**
|
|
13791
|
+
* Hard bound on held captures. Reached only if every camera on the hub triggers
|
|
13792
|
+
* one inside a single TTL; the oldest is dropped first, which costs a
|
|
13793
|
+
* fifteen-minute-old picture nobody is waiting for.
|
|
13794
|
+
*/
|
|
13795
|
+
var MAX_CAPTURES = 64;
|
|
13796
|
+
var NcStillShelf = class {
|
|
13797
|
+
deps;
|
|
13798
|
+
/** captureId → the photograph. */
|
|
13799
|
+
captures = /* @__PURE__ */ new Map();
|
|
13800
|
+
/** ownerId → captureId. Several owners may name one capture (the reuse window). */
|
|
13801
|
+
owners = /* @__PURE__ */ new Map();
|
|
13802
|
+
/** deviceId → its newest capture, for the reuse window. */
|
|
13803
|
+
newest = /* @__PURE__ */ new Map();
|
|
13804
|
+
constructor(deps) {
|
|
13805
|
+
this.deps = deps;
|
|
13806
|
+
}
|
|
13807
|
+
/**
|
|
13808
|
+
* Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
|
|
13809
|
+
* subject should name. SYNCHRONOUS by contract: the caller is on the trigger
|
|
13810
|
+
* path and the outbox row is built from what this returns.
|
|
13811
|
+
*/
|
|
13812
|
+
capture(deviceId, atMs, trigger) {
|
|
13813
|
+
const now = this.deps.now();
|
|
13814
|
+
this.prune(now);
|
|
13815
|
+
const ownerId = `${NC_STILL_SHELF_PREFIX}${(0, node_crypto.randomUUID)()}`;
|
|
13816
|
+
const recent = this.newest.get(deviceId);
|
|
13817
|
+
if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
|
|
13818
|
+
this.owners.set(ownerId, recent.captureId);
|
|
13819
|
+
return ownerId;
|
|
13820
|
+
}
|
|
13821
|
+
const captureId = (0, node_crypto.randomUUID)();
|
|
13822
|
+
this.captures.set(captureId, {
|
|
13823
|
+
deviceId,
|
|
13824
|
+
trigger,
|
|
13825
|
+
startedAt: now,
|
|
13826
|
+
expiresAt: now + NC_STILL_SHELF_TTL_MS,
|
|
13827
|
+
files: []
|
|
13828
|
+
});
|
|
13829
|
+
this.newest.set(deviceId, {
|
|
13830
|
+
captureId,
|
|
13831
|
+
at: now
|
|
13832
|
+
});
|
|
13833
|
+
this.owners.set(ownerId, captureId);
|
|
13834
|
+
this.fetch(captureId, deviceId, atMs, trigger);
|
|
13835
|
+
return ownerId;
|
|
13836
|
+
}
|
|
13837
|
+
/**
|
|
13838
|
+
* The media an owner id holds, or `undefined` when this shelf never minted
|
|
13839
|
+
* it. An EMPTY array is a different answer: the capture exists and has not
|
|
13840
|
+
* landed (or never will), which is exactly the case the dispatcher's bounded
|
|
13841
|
+
* wait and its `no still could be resolved` line are for.
|
|
13842
|
+
*/
|
|
13843
|
+
get(ownerId) {
|
|
13844
|
+
const captureId = this.owners.get(ownerId);
|
|
13845
|
+
if (captureId === void 0) return void 0;
|
|
13846
|
+
return this.captures.get(captureId)?.files ?? [];
|
|
13847
|
+
}
|
|
13848
|
+
/** Drop expired captures and the owners that named them. */
|
|
13849
|
+
prune(now) {
|
|
13850
|
+
for (const [captureId, held] of this.captures) {
|
|
13851
|
+
if (held.expiresAt > now) continue;
|
|
13852
|
+
this.captures.delete(captureId);
|
|
13853
|
+
if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
|
|
13854
|
+
}
|
|
13855
|
+
while (this.captures.size > MAX_CAPTURES) {
|
|
13856
|
+
const oldest = this.captures.keys().next();
|
|
13857
|
+
if (oldest.done === true) break;
|
|
13858
|
+
const held = this.captures.get(oldest.value);
|
|
13859
|
+
this.captures.delete(oldest.value);
|
|
13860
|
+
if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
|
|
13861
|
+
}
|
|
13862
|
+
for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
|
|
13863
|
+
}
|
|
13864
|
+
/** Drop everything (shutdown). */
|
|
13865
|
+
clear() {
|
|
13866
|
+
this.captures.clear();
|
|
13867
|
+
this.owners.clear();
|
|
13868
|
+
this.newest.clear();
|
|
13869
|
+
}
|
|
13870
|
+
async fetch(captureId, deviceId, atMs, trigger) {
|
|
13871
|
+
try {
|
|
13872
|
+
const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
|
|
13873
|
+
if (shot === null || shot.base64.length === 0) {
|
|
13874
|
+
this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
|
|
13875
|
+
return;
|
|
13876
|
+
}
|
|
13877
|
+
const raw = Buffer.from(shot.base64, "base64");
|
|
13878
|
+
let data = raw;
|
|
13879
|
+
try {
|
|
13880
|
+
data = await downscaleFullFrameJpeg(raw, 960, 540);
|
|
13881
|
+
} catch {}
|
|
13882
|
+
const held = this.captures.get(captureId);
|
|
13883
|
+
if (held === void 0) return;
|
|
13884
|
+
this.captures.set(captureId, {
|
|
13885
|
+
...held,
|
|
13886
|
+
files: stillShelfMedia(data, atMs)
|
|
13887
|
+
});
|
|
13888
|
+
this.deps.logger.info(`${trigger} still captured`, {
|
|
13889
|
+
tags: { deviceId },
|
|
13890
|
+
meta: {
|
|
13891
|
+
trigger,
|
|
13892
|
+
bytes: data.byteLength,
|
|
13893
|
+
tookMs: this.deps.now() - held.startedAt
|
|
13894
|
+
}
|
|
13895
|
+
});
|
|
13896
|
+
} catch (err) {
|
|
13897
|
+
this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
|
|
13898
|
+
}
|
|
13899
|
+
}
|
|
13900
|
+
/**
|
|
13901
|
+
* A branch that drops work says so. This one costs the operator the picture
|
|
13902
|
+
* on a notification they DID receive, so it is a warn and it carries the
|
|
13903
|
+
* camera — the only key the question is ever asked with.
|
|
13904
|
+
*/
|
|
13905
|
+
reportMiss(deviceId, trigger, reason) {
|
|
13906
|
+
this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
|
|
13907
|
+
tags: { deviceId },
|
|
13908
|
+
meta: {
|
|
13909
|
+
trigger,
|
|
13910
|
+
reason
|
|
13911
|
+
}
|
|
13912
|
+
});
|
|
13913
|
+
}
|
|
13914
|
+
};
|
|
13915
|
+
/**
|
|
13916
|
+
* The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
|
|
13917
|
+
*
|
|
13918
|
+
* `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
|
|
13919
|
+
* and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
|
|
13920
|
+
* `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
|
|
13921
|
+
* no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
|
|
13922
|
+
* NOTHING and the notification ships text-only with the ordinary line saying
|
|
13923
|
+
* so. Inventing a centre crop would answer a different question from the one
|
|
13924
|
+
* the operator asked.
|
|
13925
|
+
*/
|
|
13926
|
+
function stillShelfMedia(data, timestamp) {
|
|
13927
|
+
const base64 = data.toString("base64");
|
|
13928
|
+
const file = (kind) => ({
|
|
13929
|
+
key: `${NC_STILL_SHELF_PREFIX}${kind}`,
|
|
13930
|
+
kind,
|
|
13931
|
+
base64,
|
|
13932
|
+
sizeBytes: data.byteLength,
|
|
13933
|
+
timestamp
|
|
13934
|
+
});
|
|
13935
|
+
return [
|
|
13936
|
+
file("keyFrameSmall"),
|
|
13937
|
+
file("keyFrame"),
|
|
13938
|
+
file("fullFrame")
|
|
13939
|
+
];
|
|
13940
|
+
}
|
|
13941
|
+
/** Reject if `promise` does not settle within `ms`. */
|
|
13942
|
+
function withTimeout$3(promise, ms) {
|
|
13943
|
+
return new Promise((resolve, reject) => {
|
|
13944
|
+
const timer = setTimeout(() => {
|
|
13945
|
+
reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
|
|
13946
|
+
}, ms);
|
|
13947
|
+
promise.then((value) => {
|
|
13948
|
+
clearTimeout(timer);
|
|
13949
|
+
resolve(value);
|
|
13950
|
+
}, (err) => {
|
|
13951
|
+
clearTimeout(timer);
|
|
13952
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
13953
|
+
});
|
|
13954
|
+
});
|
|
13955
|
+
}
|
|
13792
13956
|
//#endregion
|
|
13793
13957
|
//#region src/notification-center/summary/summary-ai.ts
|
|
13794
13958
|
/**
|
|
@@ -21863,6 +22027,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
21863
22027
|
...rule.template !== void 0 ? { template: rule.template } : {},
|
|
21864
22028
|
media: noMedia ? "none" : mediaRule.media.attach,
|
|
21865
22029
|
...!noMedia && mediaRule.media.zoneCrop === true && cropZoneIds !== void 0 ? { mediaZoneIds: [...cropZoneIds] } : {},
|
|
22030
|
+
...!noMedia && cropZoneIds !== void 0 ? { triggerZoneIds: [...cropZoneIds] } : {},
|
|
21866
22031
|
...confirm !== void 0 ? {
|
|
21867
22032
|
confirm,
|
|
21868
22033
|
...cooldown !== void 0 ? { cooldown } : {}
|
|
@@ -25939,37 +26104,61 @@ function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples)
|
|
|
25939
26104
|
return eligible;
|
|
25940
26105
|
}
|
|
25941
26106
|
/**
|
|
25942
|
-
*
|
|
25943
|
-
* `modelId` AND
|
|
25944
|
-
*
|
|
25945
|
-
*
|
|
26107
|
+
* THE scoring authority. Every identity with at least one sample comparable to
|
|
26108
|
+
* the probe (same `modelId` AND same dimension — model-version safety), scored
|
|
26109
|
+
* against its best such sample, sorted best-first.
|
|
26110
|
+
*
|
|
26111
|
+
* Extracted from `matchEmbedding`, which is now a thin decision on top of it,
|
|
26112
|
+
* so that "what would the pipeline score this face" has exactly ONE
|
|
26113
|
+
* implementation. `face-rescore.ts` — the operator's recompute button — reads
|
|
26114
|
+
* the same rows the matcher decides from; a second scoring pass written for the
|
|
26115
|
+
* UI would be free to drift, and a UI that disagrees with the pipeline is worse
|
|
26116
|
+
* than no UI.
|
|
25946
26117
|
*/
|
|
25947
|
-
function
|
|
26118
|
+
function rankIdentities(probe, gallery, minIdentitySamples) {
|
|
25948
26119
|
const probeVec = new Float32Array(probe.embedding);
|
|
25949
|
-
const eligible = eligibleIdentities(gallery, probe.modelId, probe.embedding.length, opts.minIdentitySamples ?? 1);
|
|
25950
26120
|
const bestByIdentity = /* @__PURE__ */ new Map();
|
|
26121
|
+
const countByIdentity = /* @__PURE__ */ new Map();
|
|
25951
26122
|
for (const s of gallery) {
|
|
25952
26123
|
if (s.modelId !== probe.modelId) continue;
|
|
25953
26124
|
if (s.embedding.length !== probe.embedding.length) continue;
|
|
25954
|
-
if (!eligible.has(s.identityId)) continue;
|
|
25955
26125
|
const score = require_dist.cosineSimilarity(probeVec, new Float32Array(s.embedding));
|
|
25956
26126
|
const prev = bestByIdentity.get(s.identityId);
|
|
25957
26127
|
if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
|
|
26128
|
+
countByIdentity.set(s.identityId, (countByIdentity.get(s.identityId) ?? 0) + 1);
|
|
25958
26129
|
}
|
|
25959
|
-
|
|
25960
|
-
|
|
25961
|
-
|
|
25962
|
-
|
|
25963
|
-
|
|
25964
|
-
|
|
25965
|
-
|
|
25966
|
-
|
|
25967
|
-
|
|
25968
|
-
|
|
25969
|
-
|
|
26130
|
+
const rows = [];
|
|
26131
|
+
for (const [identityId, score] of bestByIdentity) {
|
|
26132
|
+
const sampleCount = countByIdentity.get(identityId) ?? 0;
|
|
26133
|
+
rows.push({
|
|
26134
|
+
identityId,
|
|
26135
|
+
score,
|
|
26136
|
+
sampleCount,
|
|
26137
|
+
eligible: sampleCount >= minIdentitySamples
|
|
26138
|
+
});
|
|
26139
|
+
}
|
|
26140
|
+
rows.sort((a, b) => b.score - a.score);
|
|
26141
|
+
return rows;
|
|
26142
|
+
}
|
|
26143
|
+
/**
|
|
26144
|
+
* Match a probe embedding against the gallery. Only samples with the same
|
|
26145
|
+
* `modelId` AND the same dimension are compared (model-version safety). Returns
|
|
26146
|
+
* the best identity when its score ≥ threshold and it beats the best OTHER
|
|
26147
|
+
* identity by ≥ margin; otherwise null.
|
|
26148
|
+
*
|
|
26149
|
+
* The scores come from {@link rankIdentities}; this function is only the
|
|
26150
|
+
* decision (eligibility filter, threshold, ambiguity guard).
|
|
26151
|
+
*/
|
|
26152
|
+
function matchEmbedding(probe, gallery, opts) {
|
|
26153
|
+
const ranked = rankIdentities(probe, gallery, opts.minIdentitySamples ?? 1).filter((r) => r.eligible);
|
|
26154
|
+
const best = ranked[0];
|
|
26155
|
+
if (best === void 0) return null;
|
|
26156
|
+
if (best.score < opts.threshold) return null;
|
|
26157
|
+
const second = ranked[1];
|
|
26158
|
+
if (second !== void 0 && best.score - second.score < opts.margin) return null;
|
|
25970
26159
|
return {
|
|
25971
|
-
identityId:
|
|
25972
|
-
score:
|
|
26160
|
+
identityId: best.identityId,
|
|
26161
|
+
score: best.score
|
|
25973
26162
|
};
|
|
25974
26163
|
}
|
|
25975
26164
|
/**
|
|
@@ -26108,17 +26297,6 @@ function updateTrackAggregate(prev, match, opts) {
|
|
|
26108
26297
|
/** Attribution of a face identity NAMED BY THE OPERATOR in the gallery. */
|
|
26109
26298
|
var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
|
|
26110
26299
|
/**
|
|
26111
|
-
* Model id assumed for a face row that recorded none.
|
|
26112
|
-
*
|
|
26113
|
-
* Every row written before 2026-08-20 predates `Face.embeddingModelId`, and
|
|
26114
|
-
* every one of them was embedded with `arcface-r100` — it was the only face
|
|
26115
|
-
* model the step had ever defaulted to. So this is a statement about history,
|
|
26116
|
-
* NOT a default for new work: a face that names its model is stamped with the
|
|
26117
|
-
* model it names, because a sample that lies about its feature space defeats
|
|
26118
|
-
* the only guard `face-matcher` has.
|
|
26119
|
-
*/
|
|
26120
|
-
var LEGACY_MODEL_ID = "arcface-r100";
|
|
26121
|
-
/**
|
|
26122
26300
|
* How many face crops `listRecentFaces` resolves at once.
|
|
26123
26301
|
*
|
|
26124
26302
|
* Each crop costs a store round-trip plus a disk read, so the page is I/O-bound
|
|
@@ -26360,7 +26538,7 @@ var FaceGalleryProvider = class {
|
|
|
26360
26538
|
}
|
|
26361
26539
|
const currentFace = await this.faceStore.get(faceId);
|
|
26362
26540
|
if (!currentFace) throw new Error(`FaceGalleryProvider.assignFace: face disappeared after pre-cleanup: ${faceId}`);
|
|
26363
|
-
const enrolledModelId = currentFace.embeddingModelId ??
|
|
26541
|
+
const enrolledModelId = currentFace.embeddingModelId ?? "arcface-r100";
|
|
26364
26542
|
const verdict = enrollmentPromiscuity(currentFace.embedding, enrolledModelId, identityId, await this.identityStore.loadGallery());
|
|
26365
26543
|
if (verdict !== null) {
|
|
26366
26544
|
this.logger.warn("assignFace refused: embedding too close to a FOREIGN identity sample", {
|
|
@@ -26533,6 +26711,316 @@ var FaceGalleryProvider = class {
|
|
|
26533
26711
|
}
|
|
26534
26712
|
};
|
|
26535
26713
|
//#endregion
|
|
26714
|
+
//#region src/pipeline-analytics/face-rescore.ts
|
|
26715
|
+
/**
|
|
26716
|
+
* face-rescore — score ONE face against the gallery AS IT IS RIGHT NOW.
|
|
26717
|
+
*
|
|
26718
|
+
* ## Why this exists: the suggestion on a track is stale by construction
|
|
26719
|
+
*
|
|
26720
|
+
* `Face.suggestedIdentityId` / `suggestedMatchScore` are not computed when you
|
|
26721
|
+
* look at a track. They are computed DURING live detection, frame by frame
|
|
26722
|
+
* (`face-recognizer.ts` — the suggestion band), and the per-track peak is
|
|
26723
|
+
* persisted when the track CLOSES. They are a photograph of what the gallery
|
|
26724
|
+
* said at that instant.
|
|
26725
|
+
*
|
|
26726
|
+
* So every enrolment after that instant is invisible to them. The operator's
|
|
26727
|
+
* real workflow is the exact case this breaks: assign ten faces to build a
|
|
26728
|
+
* person up, then open an older unassigned track and ask "does it match NOW?"
|
|
26729
|
+
* — and read a number that was decided before any of those ten existed. There
|
|
26730
|
+
* is no background job that refreshes it and there should not be one (the
|
|
26731
|
+
* gallery changes on every assign; re-scoring every buffered face each time is
|
|
26732
|
+
* work nobody asked for). The honest fix is to make the question askable on
|
|
26733
|
+
* demand. That is this module, and that is the button.
|
|
26734
|
+
*
|
|
26735
|
+
* ## What it does NOT do
|
|
26736
|
+
*
|
|
26737
|
+
* It does not run inference. The face's embedding is already persisted, and
|
|
26738
|
+
* matching is pure arithmetic over vectors — so a recompute is a read plus a
|
|
26739
|
+
* few thousand dot products, not a pipeline pass. Re-embedding is a SEPARATE,
|
|
26740
|
+
* explicitly-requested action (see the action's `reembed` flag): a button
|
|
26741
|
+
* labelled "recompute" must never silently spend GPU.
|
|
26742
|
+
*
|
|
26743
|
+
* ## The model gate is not an optimisation, it is the difference between a
|
|
26744
|
+
* number and noise
|
|
26745
|
+
*
|
|
26746
|
+
* A cosine between vectors from two different face models is a well-formed
|
|
26747
|
+
* float with no meaning. `face-matcher` has always refused to compare across
|
|
26748
|
+
* `modelId`, which means a face embedded before a model flip scores against
|
|
26749
|
+
* NOTHING — and the trap is that "nothing" renders identically to "no match".
|
|
26750
|
+
* This module names that state (`model-mismatch`) so the UI can say "this face
|
|
26751
|
+
* is in an old feature space" instead of showing 0% and letting the operator
|
|
26752
|
+
* conclude the person is not there.
|
|
26753
|
+
*/
|
|
26754
|
+
/** The empty report every "cannot score" path returns, so shape never varies. */
|
|
26755
|
+
function emptyReport(verdict, input, comparable) {
|
|
26756
|
+
return {
|
|
26757
|
+
verdict,
|
|
26758
|
+
comparable,
|
|
26759
|
+
probeModelId: input.probe.modelId,
|
|
26760
|
+
clusterModelId: input.clusterModelId,
|
|
26761
|
+
params: input.params,
|
|
26762
|
+
identities: []
|
|
26763
|
+
};
|
|
26764
|
+
}
|
|
26765
|
+
/**
|
|
26766
|
+
* Decide the verdict from the ELIGIBLE ranking, using exactly the gates
|
|
26767
|
+
* `matchEmbedding` uses — it is called here rather than re-implemented, so the
|
|
26768
|
+
* "would auto-assign" claim is the matcher's own answer and cannot drift.
|
|
26769
|
+
*/
|
|
26770
|
+
function decide(eligible, input) {
|
|
26771
|
+
const best = eligible[0];
|
|
26772
|
+
if (best === void 0) return {
|
|
26773
|
+
verdict: "no-eligible-identity",
|
|
26774
|
+
winnerId: null
|
|
26775
|
+
};
|
|
26776
|
+
const match = matchEmbedding(input.probe, input.gallery, {
|
|
26777
|
+
threshold: input.params.threshold,
|
|
26778
|
+
margin: input.params.margin,
|
|
26779
|
+
minIdentitySamples: input.params.minIdentitySamples
|
|
26780
|
+
});
|
|
26781
|
+
if (match !== null) return {
|
|
26782
|
+
verdict: "auto-assignable",
|
|
26783
|
+
winnerId: match.identityId
|
|
26784
|
+
};
|
|
26785
|
+
if (best.score >= input.params.threshold) return {
|
|
26786
|
+
verdict: "ambiguous",
|
|
26787
|
+
winnerId: null
|
|
26788
|
+
};
|
|
26789
|
+
if (best.score >= input.params.suggestionMinCosine) return {
|
|
26790
|
+
verdict: "suggestion",
|
|
26791
|
+
winnerId: null
|
|
26792
|
+
};
|
|
26793
|
+
return {
|
|
26794
|
+
verdict: "below-threshold",
|
|
26795
|
+
winnerId: null
|
|
26796
|
+
};
|
|
26797
|
+
}
|
|
26798
|
+
/**
|
|
26799
|
+
* Score one probe against the gallery as it stands, and say what the pipeline
|
|
26800
|
+
* would do with it. Pure: every input is passed in, nothing is read or written.
|
|
26801
|
+
*/
|
|
26802
|
+
function buildFaceRescoreReport(input) {
|
|
26803
|
+
if (input.probe.modelId !== input.clusterModelId) return emptyReport("model-mismatch", input, false);
|
|
26804
|
+
const ranked = rankIdentities(input.probe, input.gallery, input.params.minIdentitySamples);
|
|
26805
|
+
if (ranked.length === 0) return emptyReport("empty-gallery", input, true);
|
|
26806
|
+
const eligible = ranked.filter((r) => r.eligible);
|
|
26807
|
+
const { verdict, winnerId } = decide(eligible, input);
|
|
26808
|
+
const identities = ranked.map((r) => ({
|
|
26809
|
+
identityId: r.identityId,
|
|
26810
|
+
name: input.identityNames.get(r.identityId) ?? r.identityId,
|
|
26811
|
+
score: r.score,
|
|
26812
|
+
sampleCount: r.sampleCount,
|
|
26813
|
+
eligible: r.eligible,
|
|
26814
|
+
meetsThreshold: r.score >= input.params.threshold,
|
|
26815
|
+
wouldAutoAssign: r.identityId === winnerId
|
|
26816
|
+
}));
|
|
26817
|
+
const [best, second] = eligible;
|
|
26818
|
+
return {
|
|
26819
|
+
verdict,
|
|
26820
|
+
comparable: true,
|
|
26821
|
+
probeModelId: input.probe.modelId,
|
|
26822
|
+
clusterModelId: input.clusterModelId,
|
|
26823
|
+
params: input.params,
|
|
26824
|
+
identities,
|
|
26825
|
+
...best !== void 0 && second !== void 0 ? { runnerUpGap: best.score - second.score } : {}
|
|
26826
|
+
};
|
|
26827
|
+
}
|
|
26828
|
+
//#endregion
|
|
26829
|
+
//#region src/pipeline-analytics/face-rescore-actions.ts
|
|
26830
|
+
/**
|
|
26831
|
+
* face-rescore-actions — "score this face against the gallery AS IT IS NOW".
|
|
26832
|
+
*
|
|
26833
|
+
* ## Why an `addons.custom` action and not a `faceGallery` cap method
|
|
26834
|
+
*
|
|
26835
|
+
* Same reason `photo.*`, `embedding.*` and `debug.*` ride this bridge: a cap
|
|
26836
|
+
* method's wire schema lives in `@camstack/types`, which travels inside the
|
|
26837
|
+
* `@camstack/server` closure, so a new one is not callable until a server train
|
|
26838
|
+
* ships — however current the addon is. Here the schema lives in the addon's
|
|
26839
|
+
* own dist and the hub forwards an opaque envelope, so `camstack deploy` is the
|
|
26840
|
+
* whole delivery.
|
|
26841
|
+
*
|
|
26842
|
+
* ## Why re-embedding is an explicit flag and not a fallback
|
|
26843
|
+
*
|
|
26844
|
+
* `face.rescoreTrack` with `reembed: false` (the default) reads a persisted
|
|
26845
|
+
* vector and does arithmetic — microseconds, no GPU, no node hop. With
|
|
26846
|
+
* `reembed: true` it runs the `face-embedding` step on a cluster node. Those
|
|
26847
|
+
* are different enough in cost and in consequence (a re-embed WRITES the face
|
|
26848
|
+
* row) that a button labelled "recompute" must not choose between them on the
|
|
26849
|
+
* operator's behalf. The report says whether a re-embed is possible
|
|
26850
|
+
* (`reembedAvailable`); the operator asks for it.
|
|
26851
|
+
*
|
|
26852
|
+
* See `face-rescore.ts` for why the number already on the track is stale by
|
|
26853
|
+
* construction — that is the whole reason this action exists.
|
|
26854
|
+
*/
|
|
26855
|
+
var FaceRescoreVerdictSchema = require_dist._enum([
|
|
26856
|
+
"auto-assignable",
|
|
26857
|
+
"ambiguous",
|
|
26858
|
+
"suggestion",
|
|
26859
|
+
"below-threshold",
|
|
26860
|
+
"no-eligible-identity",
|
|
26861
|
+
"empty-gallery",
|
|
26862
|
+
"model-mismatch"
|
|
26863
|
+
]);
|
|
26864
|
+
var FaceRescoreParamsSchema = require_dist.object({
|
|
26865
|
+
threshold: require_dist.number(),
|
|
26866
|
+
margin: require_dist.number(),
|
|
26867
|
+
minIdentitySamples: require_dist.number().int(),
|
|
26868
|
+
/** DERIVED from `threshold`, never stored (D222) — see `suggestionBandFactor`. */
|
|
26869
|
+
suggestionMinCosine: require_dist.number(),
|
|
26870
|
+
/** The fraction of `threshold` that `suggestionMinCosine` is, so the report
|
|
26871
|
+
* states the RULE and not only its current result. */
|
|
26872
|
+
suggestionBandFactor: require_dist.number()
|
|
26873
|
+
});
|
|
26874
|
+
var FaceRescoreIdentityRowSchema = require_dist.object({
|
|
26875
|
+
identityId: require_dist.string(),
|
|
26876
|
+
name: require_dist.string(),
|
|
26877
|
+
/** Raw cosine. The UI formats; the wire stays in the matcher's units. */
|
|
26878
|
+
score: require_dist.number(),
|
|
26879
|
+
sampleCount: require_dist.number().int(),
|
|
26880
|
+
eligible: require_dist.boolean(),
|
|
26881
|
+
meetsThreshold: require_dist.boolean(),
|
|
26882
|
+
wouldAutoAssign: require_dist.boolean()
|
|
26883
|
+
});
|
|
26884
|
+
/**
|
|
26885
|
+
* Why a re-embed did not happen, in the addon's vocabulary.
|
|
26886
|
+
*
|
|
26887
|
+
* `not-a-template` is the one that matters and the one that is easy to get
|
|
26888
|
+
* wrong: a face's stored crop is the 112×112 ArcFace template ONLY when the
|
|
26889
|
+
* detection carried landmarks. When it does not, the crop is a padded bbox cut
|
|
26890
|
+
* — and feeding THAT to `face-embedding` as a whole frame produces a vector
|
|
26891
|
+
* that is plausible and useless, exactly the trap `face-reembed-pass.ts`
|
|
26892
|
+
* documents from the other direction. Refusing is the honest answer.
|
|
26893
|
+
*/
|
|
26894
|
+
var FaceReembedRefusalSchema = require_dist._enum([
|
|
26895
|
+
"not-requested",
|
|
26896
|
+
"no-pixels",
|
|
26897
|
+
"not-a-template",
|
|
26898
|
+
"no-capable-node",
|
|
26899
|
+
"failed",
|
|
26900
|
+
"not-persisted"
|
|
26901
|
+
]);
|
|
26902
|
+
var FaceReembedReportSchema = require_dist.object({
|
|
26903
|
+
attempted: require_dist.boolean(),
|
|
26904
|
+
/** True only when a NEW vector was embedded AND read back off the face row. */
|
|
26905
|
+
succeeded: require_dist.boolean(),
|
|
26906
|
+
reason: FaceReembedRefusalSchema.optional(),
|
|
26907
|
+
detail: require_dist.string().optional(),
|
|
26908
|
+
/** The model the re-embed pinned — the cluster row's, never a substitute. */
|
|
26909
|
+
modelId: require_dist.string().optional()
|
|
26910
|
+
});
|
|
26911
|
+
var FaceRescoreInputSchema = require_dist.object({
|
|
26912
|
+
deviceId: require_dist.number().int(),
|
|
26913
|
+
trackId: require_dist.string().min(1),
|
|
26914
|
+
/**
|
|
26915
|
+
* Run `face-embedding` over the face's stored aligned template first, so a
|
|
26916
|
+
* face left in a superseded feature space becomes comparable again. Costs a
|
|
26917
|
+
* node round-trip and WRITES the face row. Never implicit.
|
|
26918
|
+
*/
|
|
26919
|
+
reembed: require_dist.boolean().default(false)
|
|
26920
|
+
});
|
|
26921
|
+
var FaceRescoreResultSchema = require_dist.object({
|
|
26922
|
+
faceId: require_dist.string(),
|
|
26923
|
+
deviceId: require_dist.number().int(),
|
|
26924
|
+
trackId: require_dist.string(),
|
|
26925
|
+
/** The face's current assignment, so the UI can mark "already this person". */
|
|
26926
|
+
assigned: require_dist.boolean(),
|
|
26927
|
+
assignedIdentityId: require_dist.string().optional(),
|
|
26928
|
+
/** What the track has been SHOWING until now — persisted at detection time.
|
|
26929
|
+
* Returned so the UI can put the stale number beside the fresh one. */
|
|
26930
|
+
storedSuggestedIdentityId: require_dist.string().optional(),
|
|
26931
|
+
storedSuggestedMatchScore: require_dist.number().optional(),
|
|
26932
|
+
verdict: FaceRescoreVerdictSchema,
|
|
26933
|
+
comparable: require_dist.boolean(),
|
|
26934
|
+
probeModelId: require_dist.string(),
|
|
26935
|
+
clusterModelId: require_dist.string(),
|
|
26936
|
+
params: FaceRescoreParamsSchema,
|
|
26937
|
+
identities: require_dist.array(FaceRescoreIdentityRowSchema).readonly(),
|
|
26938
|
+
runnerUpGap: require_dist.number().optional(),
|
|
26939
|
+
/** Whether a `reembed: true` retry could actually do anything. Lets the UI
|
|
26940
|
+
* offer the escape hatch only when it exists. */
|
|
26941
|
+
reembedAvailable: require_dist.boolean(),
|
|
26942
|
+
reembed: FaceReembedReportSchema
|
|
26943
|
+
});
|
|
26944
|
+
var faceRescoreActions = require_dist.defineCustomActions({
|
|
26945
|
+
/**
|
|
26946
|
+
* Read-only by default. `admin` because the report names every enrolled
|
|
26947
|
+
* person and how well an arbitrary face matches them — the gallery's shape,
|
|
26948
|
+
* which is not a viewer-level fact.
|
|
26949
|
+
*/
|
|
26950
|
+
"face.rescoreTrack": require_dist.customAction(FaceRescoreInputSchema, FaceRescoreResultSchema, {
|
|
26951
|
+
kind: "mutation",
|
|
26952
|
+
auth: "admin"
|
|
26953
|
+
}) });
|
|
26954
|
+
//#endregion
|
|
26955
|
+
//#region src/pipeline-analytics/pipeline/closed-row-media-gate.ts
|
|
26956
|
+
var DEFAULT_LOG_MIN_GAP_MS = 6e4;
|
|
26957
|
+
var DEFAULT_MAX_TRACKED = 512;
|
|
26958
|
+
var WRITE = {
|
|
26959
|
+
refuse: false,
|
|
26960
|
+
log: false,
|
|
26961
|
+
closedAgeMs: 0,
|
|
26962
|
+
suppressed: 0
|
|
26963
|
+
};
|
|
26964
|
+
var ClosedRowMediaGate = class {
|
|
26965
|
+
logMinGapMs;
|
|
26966
|
+
maxTracked;
|
|
26967
|
+
/** trackId → sampling state, insertion-ordered so the oldest evicts first. */
|
|
26968
|
+
sampling = /* @__PURE__ */ new Map();
|
|
26969
|
+
constructor(options = {}) {
|
|
26970
|
+
this.logMinGapMs = options.logMinGapMs ?? DEFAULT_LOG_MIN_GAP_MS;
|
|
26971
|
+
this.maxTracked = options.maxTracked ?? DEFAULT_MAX_TRACKED;
|
|
26972
|
+
}
|
|
26973
|
+
/**
|
|
26974
|
+
* @param rowClosedAt when the store closed this track's row, or `undefined`
|
|
26975
|
+
* when it holds no such record — which always means WRITE.
|
|
26976
|
+
*/
|
|
26977
|
+
evaluate(trackId, rowClosedAt, nowMs) {
|
|
26978
|
+
if (rowClosedAt === void 0) return WRITE;
|
|
26979
|
+
const closedAgeMs = Math.max(0, nowMs - rowClosedAt);
|
|
26980
|
+
const existing = this.sampling.get(trackId);
|
|
26981
|
+
if (existing === void 0) {
|
|
26982
|
+
this.remember(trackId, {
|
|
26983
|
+
lastLoggedAt: nowMs,
|
|
26984
|
+
suppressed: 0
|
|
26985
|
+
});
|
|
26986
|
+
return {
|
|
26987
|
+
refuse: true,
|
|
26988
|
+
log: true,
|
|
26989
|
+
closedAgeMs,
|
|
26990
|
+
suppressed: 0
|
|
26991
|
+
};
|
|
26992
|
+
}
|
|
26993
|
+
if (nowMs - existing.lastLoggedAt < this.logMinGapMs) {
|
|
26994
|
+
existing.suppressed += 1;
|
|
26995
|
+
return {
|
|
26996
|
+
refuse: true,
|
|
26997
|
+
log: false,
|
|
26998
|
+
closedAgeMs,
|
|
26999
|
+
suppressed: existing.suppressed
|
|
27000
|
+
};
|
|
27001
|
+
}
|
|
27002
|
+
const suppressed = existing.suppressed;
|
|
27003
|
+
existing.lastLoggedAt = nowMs;
|
|
27004
|
+
existing.suppressed = 0;
|
|
27005
|
+
return {
|
|
27006
|
+
refuse: true,
|
|
27007
|
+
log: true,
|
|
27008
|
+
closedAgeMs,
|
|
27009
|
+
suppressed
|
|
27010
|
+
};
|
|
27011
|
+
}
|
|
27012
|
+
/** Drop all sampling state (addon dispose). */
|
|
27013
|
+
clear() {
|
|
27014
|
+
this.sampling.clear();
|
|
27015
|
+
}
|
|
27016
|
+
remember(trackId, state) {
|
|
27017
|
+
this.sampling.set(trackId, state);
|
|
27018
|
+
if (this.sampling.size <= this.maxTracked) return;
|
|
27019
|
+
const oldest = this.sampling.keys().next();
|
|
27020
|
+
if (!oldest.done) this.sampling.delete(oldest.value);
|
|
27021
|
+
}
|
|
27022
|
+
};
|
|
27023
|
+
//#endregion
|
|
26536
27024
|
//#region src/pipeline-analytics/pipeline/edge-clear.ts
|
|
26537
27025
|
/**
|
|
26538
27026
|
* edge-clear — pure geometry for "is the subject fully in frame?" best-frame
|
|
@@ -35355,6 +35843,210 @@ async function resolveDefaultEventMedia(deps) {
|
|
|
35355
35843
|
return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
|
|
35356
35844
|
}
|
|
35357
35845
|
//#endregion
|
|
35846
|
+
//#region src/pipeline-analytics/face-settings.ts
|
|
35847
|
+
/**
|
|
35848
|
+
* Per-device face-recognition settings. Cascade: a per-device override on top
|
|
35849
|
+
* of the global default, resolved per field (an invalid/missing value falls
|
|
35850
|
+
* back to its default — parse never throws). Mirrors `audio-detection-settings`.
|
|
35851
|
+
*/
|
|
35852
|
+
var FaceSettingsSchema = require_dist.object({
|
|
35853
|
+
/**
|
|
35854
|
+
* GLOBAL master switch only — a temporary kill for the whole face-recognition
|
|
35855
|
+
* post-processor. On by default; the recognizer runs whenever the per-device
|
|
35856
|
+
* detection pipeline produces face embeddings (the pipeline steps ARE the
|
|
35857
|
+
* per-camera control). Not a per-device override — see
|
|
35858
|
+
* `resolveGlobalFaceEnabled` / `getDeviceSettingsContribution` (the field is
|
|
35859
|
+
* stripped from the per-device schema).
|
|
35860
|
+
*/
|
|
35861
|
+
enabled: require_dist.boolean().default(true),
|
|
35862
|
+
/**
|
|
35863
|
+
* Cosine similarity (on L2-normalized arcface vectors) required to match.
|
|
35864
|
+
*
|
|
35865
|
+
* History: 0.55→0.62 on 2026-07-23, then back to 0.55 on 2026-08-18 once the
|
|
35866
|
+
* dead `assignUniquePerFrame` margin and the poisoned gallery were fixed —
|
|
35867
|
+
* with both repaired, genuine probes scored median 0.38 / p90 0.51, so 0.62
|
|
35868
|
+
* left almost nothing recognisable. That retune lived only as a RUNTIME
|
|
35869
|
+
* override until 2026-08-19; it is the schema default now, because a default
|
|
35870
|
+
* nobody runs is a decision nobody made.
|
|
35871
|
+
*
|
|
35872
|
+
* ⚠ NOT the end state. 0.55 is the least-bad value for the CURRENT crop
|
|
35873
|
+
* quality, where most of what reaches ArcFace is not a correctly aligned
|
|
35874
|
+
* face (measured 2026-08-19 over 60 live crops pulled from the hub: 20%
|
|
35875
|
+
* aligned, 18% marginal, 62% misaligned or containing no detectable face).
|
|
35876
|
+
* On correctly aligned crops the SAME models separate at a far lower
|
|
35877
|
+
* operating point — 300 LFW pairs gave genuine median 0.605 / impostor p90
|
|
35878
|
+
* 0.175, best accuracy 98.3% at threshold 0.28.
|
|
35879
|
+
*
|
|
35880
|
+
* Recalibration plan, in this order:
|
|
35881
|
+
* 1. land the landmark-precision gate (the alignment fix),
|
|
35882
|
+
* 2. re-enrol the gallery from crops produced AFTER it,
|
|
35883
|
+
* 3. re-measure genuine/impostor over a week of post-fix traffic,
|
|
35884
|
+
* 4. expect to lower this toward ~0.30, and `margin` with it.
|
|
35885
|
+
* Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
|
|
35886
|
+
* scores ~0.9 against everything, so the degenerate population sits ABOVE
|
|
35887
|
+
* any threshold you could pick — the knob cannot reach it.
|
|
35888
|
+
*/
|
|
35889
|
+
similarityThreshold: require_dist.number().min(0).max(1).default(.55),
|
|
35890
|
+
/** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
|
|
35891
|
+
* (2026-07-23), then →0.12 (2026-08-18) alongside the threshold retune —
|
|
35892
|
+
* same story and the same recalibration plan as {@link similarityThreshold}. */
|
|
35893
|
+
margin: require_dist.number().min(0).max(1).default(.12),
|
|
35894
|
+
/** Minimum face-detection confidence for a face to be considered. */
|
|
35895
|
+
minFaceConfidence: require_dist.number().min(0).max(1).default(.5),
|
|
35896
|
+
/**
|
|
35897
|
+
* Minimum face bbox size (px, shorter side, NATIVE scale when the runner
|
|
35898
|
+
* measured it, else detection-frame space) for a face to be DETECTED and
|
|
35899
|
+
* COLLECTED into the recent-faces buffer. Below this the face is dropped
|
|
35900
|
+
* BEFORE ingest/enrolment (#26.1). This is the DETECTION/collection floor —
|
|
35901
|
+
* NOT the auto-assignment floor (see {@link recognitionMinFacePx}).
|
|
35902
|
+
*/
|
|
35903
|
+
minFacePx: require_dist.number().min(0).default(30),
|
|
35904
|
+
/**
|
|
35905
|
+
* Minimum face short side (px, NATIVE scale when the runner measured it, else
|
|
35906
|
+
* detection-frame space) for a collected face to be eligible for AUTO-MATCH
|
|
35907
|
+
* (identity assignment). Separate from — and ≥ — {@link minFacePx}: faces
|
|
35908
|
+
* between `minFacePx` and this floor are still detected, cropped, and stored
|
|
35909
|
+
* in the buffer (available for MANUAL assignment), but are NEVER auto-assigned
|
|
35910
|
+
* an identity. ArcFace embeddings below ~48px are unreliable and drove the
|
|
35911
|
+
* observed false positives (2026-07-23 face-quality batch). A face below this
|
|
35912
|
+
* floor keeps `recognizedIdentityId` UNSET (fail-safe).
|
|
35913
|
+
*/
|
|
35914
|
+
recognitionMinFacePx: require_dist.number().min(0).default(48),
|
|
35915
|
+
/**
|
|
35916
|
+
* Minimum enrolled-sample count an identity must have before it can be an
|
|
35917
|
+
* AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
|
|
35918
|
+
* enrolment attracted 81% of matches); identities below this are excluded from
|
|
35919
|
+
* automatic matching until more samples are enrolled (#26.3).
|
|
35920
|
+
*/
|
|
35921
|
+
minIdentitySamples: require_dist.number().int().min(1).default(2),
|
|
35922
|
+
/** Frames an identity must be confirmed before a track is assigned. Floor of
|
|
35923
|
+
* 1 (0 confirmations would assign on a single noisy frame — nonsensical;
|
|
35924
|
+
* such a value falls back to the default). */
|
|
35925
|
+
confirmFrames: require_dist.number().int().min(1).default(3),
|
|
35926
|
+
/**
|
|
35927
|
+
* Upper bound on the RAW ArcFace feature magnitude a face may have and still
|
|
35928
|
+
* be embedded into the buffer / matched / enrolled. `0` (the default) DISABLES
|
|
35929
|
+
* the gate.
|
|
35930
|
+
*
|
|
35931
|
+
* This exists because a degenerate crop does not score LOW, it scores HIGH:
|
|
35932
|
+
* measured on this cluster 2026-08-18/19, 28.8% of all pairs of DIFFERENT
|
|
35933
|
+
* faces exceeded cosine 0.90 and the worst 40% of the face population had
|
|
35934
|
+
* collapsed onto effectively one vector (mean pairwise cosine 0.976). No
|
|
35935
|
+
* similarity threshold can reach that — it sits above every threshold — so the
|
|
35936
|
+
* only defence is to refuse the embedding before it is stored or compared.
|
|
35937
|
+
*
|
|
35938
|
+
* Direction: for this model the DEGENERATE crops carry the LARGER norm (the
|
|
35939
|
+
* inverse of the MagFace convention), hence a maximum. Over 140 LFW faces
|
|
35940
|
+
* through this exact model and preprocessing, usable crops sat at ≈4.4 (p95
|
|
35941
|
+
* 5.25) regardless of size, grey level or blur, while information-free crops
|
|
35942
|
+
* jumped to 10–16; a bound of 6.0 kept 97.6% of usable crops and rejected
|
|
35943
|
+
* 93.2% of degenerate ones.
|
|
35944
|
+
*
|
|
35945
|
+
* Default 0 ON PURPOSE. Those numbers are LFW's, not this cluster's, and the
|
|
35946
|
+
* magnitude has never been observable here — `result-assembler` discarded it
|
|
35947
|
+
* one line after computing it. The field and its logging ship first; set a
|
|
35948
|
+
* real bound once a week of live `face: embedding magnitude` lines says where
|
|
35949
|
+
* this cluster's populations actually sit. Enabling a gate against a number
|
|
35950
|
+
* nobody has measured in production is how recognition silently stops.
|
|
35951
|
+
*/
|
|
35952
|
+
maxEmbeddingMagnitude: require_dist.number().min(0).default(0),
|
|
35953
|
+
/** Max buffered faces kept per device. */
|
|
35954
|
+
bufferMaxPerDevice: require_dist.number().int().min(0).default(50)
|
|
35955
|
+
});
|
|
35956
|
+
var FACE_DEFAULTS = FaceSettingsSchema.parse({});
|
|
35957
|
+
function resolveFaceSettings(raw) {
|
|
35958
|
+
const pick = (key) => {
|
|
35959
|
+
const parsed = FaceSettingsSchema.shape[key].safeParse(raw[key]);
|
|
35960
|
+
return parsed.success ? parsed.data : FACE_DEFAULTS[key];
|
|
35961
|
+
};
|
|
35962
|
+
return {
|
|
35963
|
+
enabled: pick("enabled"),
|
|
35964
|
+
similarityThreshold: pick("similarityThreshold"),
|
|
35965
|
+
margin: pick("margin"),
|
|
35966
|
+
minFaceConfidence: pick("minFaceConfidence"),
|
|
35967
|
+
minFacePx: pick("minFacePx"),
|
|
35968
|
+
recognitionMinFacePx: pick("recognitionMinFacePx"),
|
|
35969
|
+
minIdentitySamples: pick("minIdentitySamples"),
|
|
35970
|
+
maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
|
|
35971
|
+
confirmFrames: pick("confirmFrames"),
|
|
35972
|
+
bufferMaxPerDevice: pick("bufferMaxPerDevice")
|
|
35973
|
+
};
|
|
35974
|
+
}
|
|
35975
|
+
/**
|
|
35976
|
+
* The suggestion band opens at 80% of the assignment threshold.
|
|
35977
|
+
*
|
|
35978
|
+
* ## Why derived and not a knob
|
|
35979
|
+
*
|
|
35980
|
+
* `suggestionMinCosine` used to be its own operator-overridable field. Two
|
|
35981
|
+
* independent numbers described ONE band, so they could contradict each other
|
|
35982
|
+
* — and on the live hub they did. The 2026-08-19 retune lowered
|
|
35983
|
+
* `similarityThreshold` to 0.45 and left the suggestion floor at its 0.50
|
|
35984
|
+
* default, which INVERTS the band: `[0.50, 0.45)` is empty, so from that moment
|
|
35985
|
+
* the recognizer could not produce a single suggestion. Nothing errored,
|
|
35986
|
+
* nothing logged, and the form showed two plausible numbers side by side. That
|
|
35987
|
+
* is the D62 shape — a second authority over a fact that already had one — and
|
|
35988
|
+
* the fix is the same: delete the second authority rather than police it.
|
|
35989
|
+
*
|
|
35990
|
+
* Derived, the band cannot invert for ANY threshold, which is the property
|
|
35991
|
+
* `face-settings.spec.ts` pins over the whole domain rather than at samples.
|
|
35992
|
+
*
|
|
35993
|
+
* ## Why 0.8
|
|
35994
|
+
*
|
|
35995
|
+
* The operator's number, and it matches what the band was for: a near-miss is
|
|
35996
|
+
* a probe that scored within a fifth of the bar. At the schema default (0.55)
|
|
35997
|
+
* it reproduces a 0.44 floor — close to the 0.50 the band shipped with in
|
|
35998
|
+
* 2026-07 — and it TRACKS every future retune, which is the whole point: the
|
|
35999
|
+
* recalibration plan on {@link FaceSettingsSchema.shape.similarityThreshold}
|
|
36000
|
+
* expects the threshold to fall toward ~0.30 once alignment lands, and a fixed
|
|
36001
|
+
* floor would have to be remembered and moved by hand each time.
|
|
36002
|
+
*/
|
|
36003
|
+
var SUGGESTION_BAND_FACTOR = .8;
|
|
36004
|
+
/** Decimal places the derived floor is rounded to. `0.8 * 0.45` is
|
|
36005
|
+
* `0.36000000000000004` in IEEE754, and an operator reading "36.000000000004%"
|
|
36006
|
+
* in a report has been told the number is untrustworthy. */
|
|
36007
|
+
var DERIVED_PRECISION = 1e4;
|
|
36008
|
+
/**
|
|
36009
|
+
* The cosine floor of the suggestion band for a given assignment threshold.
|
|
36010
|
+
*
|
|
36011
|
+
* INVARIANT, total over `[0, 1]`: the result is never ABOVE the threshold, and
|
|
36012
|
+
* is strictly below it for every threshold > 0 — so the band
|
|
36013
|
+
* `[floor, threshold)` can never be empty. Rounding is applied only when it
|
|
36014
|
+
* preserves that (for a threshold small enough that rounding would reach it,
|
|
36015
|
+
* the exact product is returned instead).
|
|
36016
|
+
*/
|
|
36017
|
+
function deriveSuggestionMinCosine(similarityThreshold) {
|
|
36018
|
+
const exact = similarityThreshold * SUGGESTION_BAND_FACTOR;
|
|
36019
|
+
const rounded = Math.round(exact * DERIVED_PRECISION) / DERIVED_PRECISION;
|
|
36020
|
+
return rounded < similarityThreshold ? rounded : exact;
|
|
36021
|
+
}
|
|
36022
|
+
/**
|
|
36023
|
+
* The settings key that used to hold the suggestion floor. Named once, here,
|
|
36024
|
+
* because the ONLY code allowed to mention it now is the migration that removes
|
|
36025
|
+
* it from a store.
|
|
36026
|
+
*/
|
|
36027
|
+
var STORED_SUGGESTION_MIN_COSINE_KEY = "suggestionMinCosine";
|
|
36028
|
+
/**
|
|
36029
|
+
* Plan the removal of a persisted suggestion floor from one settings blob.
|
|
36030
|
+
*
|
|
36031
|
+
* `null` = nothing to do, which is the steady state after the first pass and
|
|
36032
|
+
* for every store that never had one. Absence of the key IS the migration
|
|
36033
|
+
* marker — no flag, no version row, and re-running is a no-op.
|
|
36034
|
+
*
|
|
36035
|
+
* The patch uses `undefined`, never `null`: the settings merge preserves an
|
|
36036
|
+
* explicit `null` as a STORED value, which would leave exactly the
|
|
36037
|
+
* accepted-but-ignored residue this exists to remove, while `undefined`
|
|
36038
|
+
* survives the merge as a key with no value and is dropped by the JSON
|
|
36039
|
+
* serialisation the blob round-trips through.
|
|
36040
|
+
*/
|
|
36041
|
+
function planStoredSuggestionPurge(raw) {
|
|
36042
|
+
const was = raw[STORED_SUGGESTION_MIN_COSINE_KEY];
|
|
36043
|
+
if (was === void 0) return null;
|
|
36044
|
+
return {
|
|
36045
|
+
was,
|
|
36046
|
+
patch: { [STORED_SUGGESTION_MIN_COSINE_KEY]: void 0 }
|
|
36047
|
+
};
|
|
36048
|
+
}
|
|
36049
|
+
//#endregion
|
|
35358
36050
|
//#region src/pipeline-analytics/pipeline/embedding-magnitude-gate.ts
|
|
35359
36051
|
/**
|
|
35360
36052
|
* `null` = accept. A verdict = refuse, carrying both numbers so the caller can
|
|
@@ -35602,7 +36294,7 @@ var FaceRecognizer = class {
|
|
|
35602
36294
|
embedding: c.embedding,
|
|
35603
36295
|
modelId: c.embeddingModelId
|
|
35604
36296
|
}, this.gallery, {
|
|
35605
|
-
threshold: settings.
|
|
36297
|
+
threshold: deriveSuggestionMinCosine(settings.similarityThreshold),
|
|
35606
36298
|
margin: settings.margin,
|
|
35607
36299
|
minIdentitySamples: settings.minIdentitySamples
|
|
35608
36300
|
});
|
|
@@ -35895,148 +36587,6 @@ var FaceRecognizer = class {
|
|
|
35895
36587
|
}
|
|
35896
36588
|
};
|
|
35897
36589
|
//#endregion
|
|
35898
|
-
//#region src/pipeline-analytics/face-settings.ts
|
|
35899
|
-
/**
|
|
35900
|
-
* Per-device face-recognition settings. Cascade: a per-device override on top
|
|
35901
|
-
* of the global default, resolved per field (an invalid/missing value falls
|
|
35902
|
-
* back to its default — parse never throws). Mirrors `audio-detection-settings`.
|
|
35903
|
-
*/
|
|
35904
|
-
var FaceSettingsSchema = require_dist.object({
|
|
35905
|
-
/**
|
|
35906
|
-
* GLOBAL master switch only — a temporary kill for the whole face-recognition
|
|
35907
|
-
* post-processor. On by default; the recognizer runs whenever the per-device
|
|
35908
|
-
* detection pipeline produces face embeddings (the pipeline steps ARE the
|
|
35909
|
-
* per-camera control). Not a per-device override — see
|
|
35910
|
-
* `resolveGlobalFaceEnabled` / `getDeviceSettingsContribution` (the field is
|
|
35911
|
-
* stripped from the per-device schema).
|
|
35912
|
-
*/
|
|
35913
|
-
enabled: require_dist.boolean().default(true),
|
|
35914
|
-
/**
|
|
35915
|
-
* Cosine similarity (on L2-normalized arcface vectors) required to match.
|
|
35916
|
-
*
|
|
35917
|
-
* History: 0.55→0.62 on 2026-07-23, then back to 0.55 on 2026-08-18 once the
|
|
35918
|
-
* dead `assignUniquePerFrame` margin and the poisoned gallery were fixed —
|
|
35919
|
-
* with both repaired, genuine probes scored median 0.38 / p90 0.51, so 0.62
|
|
35920
|
-
* left almost nothing recognisable. That retune lived only as a RUNTIME
|
|
35921
|
-
* override until 2026-08-19; it is the schema default now, because a default
|
|
35922
|
-
* nobody runs is a decision nobody made.
|
|
35923
|
-
*
|
|
35924
|
-
* ⚠ NOT the end state. 0.55 is the least-bad value for the CURRENT crop
|
|
35925
|
-
* quality, where most of what reaches ArcFace is not a correctly aligned
|
|
35926
|
-
* face (measured 2026-08-19 over 60 live crops pulled from the hub: 20%
|
|
35927
|
-
* aligned, 18% marginal, 62% misaligned or containing no detectable face).
|
|
35928
|
-
* On correctly aligned crops the SAME models separate at a far lower
|
|
35929
|
-
* operating point — 300 LFW pairs gave genuine median 0.605 / impostor p90
|
|
35930
|
-
* 0.175, best accuracy 98.3% at threshold 0.28.
|
|
35931
|
-
*
|
|
35932
|
-
* Recalibration plan, in this order:
|
|
35933
|
-
* 1. land the landmark-precision gate (the alignment fix),
|
|
35934
|
-
* 2. re-enrol the gallery from crops produced AFTER it,
|
|
35935
|
-
* 3. re-measure genuine/impostor over a week of post-fix traffic,
|
|
35936
|
-
* 4. expect to lower this toward ~0.30, and `margin` with it.
|
|
35937
|
-
* Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
|
|
35938
|
-
* scores ~0.9 against everything, so the degenerate population sits ABOVE
|
|
35939
|
-
* any threshold you could pick — the knob cannot reach it.
|
|
35940
|
-
*/
|
|
35941
|
-
similarityThreshold: require_dist.number().min(0).max(1).default(.55),
|
|
35942
|
-
/** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
|
|
35943
|
-
* (2026-07-23), then →0.12 (2026-08-18) alongside the threshold retune —
|
|
35944
|
-
* same story and the same recalibration plan as {@link similarityThreshold}. */
|
|
35945
|
-
margin: require_dist.number().min(0).max(1).default(.12),
|
|
35946
|
-
/** Minimum face-detection confidence for a face to be considered. */
|
|
35947
|
-
minFaceConfidence: require_dist.number().min(0).max(1).default(.5),
|
|
35948
|
-
/**
|
|
35949
|
-
* Minimum face bbox size (px, shorter side, NATIVE scale when the runner
|
|
35950
|
-
* measured it, else detection-frame space) for a face to be DETECTED and
|
|
35951
|
-
* COLLECTED into the recent-faces buffer. Below this the face is dropped
|
|
35952
|
-
* BEFORE ingest/enrolment (#26.1). This is the DETECTION/collection floor —
|
|
35953
|
-
* NOT the auto-assignment floor (see {@link recognitionMinFacePx}).
|
|
35954
|
-
*/
|
|
35955
|
-
minFacePx: require_dist.number().min(0).default(30),
|
|
35956
|
-
/**
|
|
35957
|
-
* Minimum face short side (px, NATIVE scale when the runner measured it, else
|
|
35958
|
-
* detection-frame space) for a collected face to be eligible for AUTO-MATCH
|
|
35959
|
-
* (identity assignment). Separate from — and ≥ — {@link minFacePx}: faces
|
|
35960
|
-
* between `minFacePx` and this floor are still detected, cropped, and stored
|
|
35961
|
-
* in the buffer (available for MANUAL assignment), but are NEVER auto-assigned
|
|
35962
|
-
* an identity. ArcFace embeddings below ~48px are unreliable and drove the
|
|
35963
|
-
* observed false positives (2026-07-23 face-quality batch). A face below this
|
|
35964
|
-
* floor keeps `recognizedIdentityId` UNSET (fail-safe).
|
|
35965
|
-
*/
|
|
35966
|
-
recognitionMinFacePx: require_dist.number().min(0).default(48),
|
|
35967
|
-
/**
|
|
35968
|
-
* Lower cosine bound of the SUGGESTION band (2026-07-24). A face whose best
|
|
35969
|
-
* gallery match MISSES auto-assignment but is still plausible surfaces as a
|
|
35970
|
-
* SUGGESTION (persisted `suggestedIdentityId`/`suggestedMatchScore`, never an
|
|
35971
|
-
* assignment) when EITHER: its match cosine is in [`suggestionMinCosine`,
|
|
35972
|
-
* `similarityThreshold`) AND its face clears the recognition size floor; OR its
|
|
35973
|
-
* cosine is ≥ `similarityThreshold` but the face is below the recognition floor
|
|
35974
|
-
* (blocked ONLY by size). Below this cosine nothing is suggested. Operator-
|
|
35975
|
-
* overridable per field, like the other face thresholds.
|
|
35976
|
-
*/
|
|
35977
|
-
suggestionMinCosine: require_dist.number().min(0).max(1).default(.5),
|
|
35978
|
-
/**
|
|
35979
|
-
* Minimum enrolled-sample count an identity must have before it can be an
|
|
35980
|
-
* AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
|
|
35981
|
-
* enrolment attracted 81% of matches); identities below this are excluded from
|
|
35982
|
-
* automatic matching until more samples are enrolled (#26.3).
|
|
35983
|
-
*/
|
|
35984
|
-
minIdentitySamples: require_dist.number().int().min(1).default(2),
|
|
35985
|
-
/** Frames an identity must be confirmed before a track is assigned. Floor of
|
|
35986
|
-
* 1 (0 confirmations would assign on a single noisy frame — nonsensical;
|
|
35987
|
-
* such a value falls back to the default). */
|
|
35988
|
-
confirmFrames: require_dist.number().int().min(1).default(3),
|
|
35989
|
-
/**
|
|
35990
|
-
* Upper bound on the RAW ArcFace feature magnitude a face may have and still
|
|
35991
|
-
* be embedded into the buffer / matched / enrolled. `0` (the default) DISABLES
|
|
35992
|
-
* the gate.
|
|
35993
|
-
*
|
|
35994
|
-
* This exists because a degenerate crop does not score LOW, it scores HIGH:
|
|
35995
|
-
* measured on this cluster 2026-08-18/19, 28.8% of all pairs of DIFFERENT
|
|
35996
|
-
* faces exceeded cosine 0.90 and the worst 40% of the face population had
|
|
35997
|
-
* collapsed onto effectively one vector (mean pairwise cosine 0.976). No
|
|
35998
|
-
* similarity threshold can reach that — it sits above every threshold — so the
|
|
35999
|
-
* only defence is to refuse the embedding before it is stored or compared.
|
|
36000
|
-
*
|
|
36001
|
-
* Direction: for this model the DEGENERATE crops carry the LARGER norm (the
|
|
36002
|
-
* inverse of the MagFace convention), hence a maximum. Over 140 LFW faces
|
|
36003
|
-
* through this exact model and preprocessing, usable crops sat at ≈4.4 (p95
|
|
36004
|
-
* 5.25) regardless of size, grey level or blur, while information-free crops
|
|
36005
|
-
* jumped to 10–16; a bound of 6.0 kept 97.6% of usable crops and rejected
|
|
36006
|
-
* 93.2% of degenerate ones.
|
|
36007
|
-
*
|
|
36008
|
-
* Default 0 ON PURPOSE. Those numbers are LFW's, not this cluster's, and the
|
|
36009
|
-
* magnitude has never been observable here — `result-assembler` discarded it
|
|
36010
|
-
* one line after computing it. The field and its logging ship first; set a
|
|
36011
|
-
* real bound once a week of live `face: embedding magnitude` lines says where
|
|
36012
|
-
* this cluster's populations actually sit. Enabling a gate against a number
|
|
36013
|
-
* nobody has measured in production is how recognition silently stops.
|
|
36014
|
-
*/
|
|
36015
|
-
maxEmbeddingMagnitude: require_dist.number().min(0).default(0),
|
|
36016
|
-
/** Max buffered faces kept per device. */
|
|
36017
|
-
bufferMaxPerDevice: require_dist.number().int().min(0).default(50)
|
|
36018
|
-
});
|
|
36019
|
-
var FACE_DEFAULTS = FaceSettingsSchema.parse({});
|
|
36020
|
-
function resolveFaceSettings(raw) {
|
|
36021
|
-
const pick = (key) => {
|
|
36022
|
-
const parsed = FaceSettingsSchema.shape[key].safeParse(raw[key]);
|
|
36023
|
-
return parsed.success ? parsed.data : FACE_DEFAULTS[key];
|
|
36024
|
-
};
|
|
36025
|
-
return {
|
|
36026
|
-
enabled: pick("enabled"),
|
|
36027
|
-
similarityThreshold: pick("similarityThreshold"),
|
|
36028
|
-
margin: pick("margin"),
|
|
36029
|
-
minFaceConfidence: pick("minFaceConfidence"),
|
|
36030
|
-
minFacePx: pick("minFacePx"),
|
|
36031
|
-
recognitionMinFacePx: pick("recognitionMinFacePx"),
|
|
36032
|
-
suggestionMinCosine: pick("suggestionMinCosine"),
|
|
36033
|
-
minIdentitySamples: pick("minIdentitySamples"),
|
|
36034
|
-
maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
|
|
36035
|
-
confirmFrames: pick("confirmFrames"),
|
|
36036
|
-
bufferMaxPerDevice: pick("bufferMaxPerDevice")
|
|
36037
|
-
};
|
|
36038
|
-
}
|
|
36039
|
-
//#endregion
|
|
36040
36590
|
//#region src/pipeline-analytics/location-aware-media-storage.ts
|
|
36041
36591
|
/**
|
|
36042
36592
|
* Location-aware blob storage for event media (entity-routing spec, Phase 3).
|
|
@@ -46223,7 +46773,8 @@ function parseCloseReason(value) {
|
|
|
46223
46773
|
}
|
|
46224
46774
|
var DEFAULT_CONFIG = {
|
|
46225
46775
|
ttlMs: 3e4,
|
|
46226
|
-
maxPositionHistory: 300
|
|
46776
|
+
maxPositionHistory: 300,
|
|
46777
|
+
closedRowMemory: 4096
|
|
46227
46778
|
};
|
|
46228
46779
|
/** `queryRecent` page-size defaults (mirrors the cap input's bounds). */
|
|
46229
46780
|
var RECENT_DEFAULT_LIMIT = 200;
|
|
@@ -46540,6 +47091,20 @@ function cloneTrack(t) {
|
|
|
46540
47091
|
}
|
|
46541
47092
|
var TrackStore = class {
|
|
46542
47093
|
active = /* @__PURE__ */ new Map();
|
|
47094
|
+
/**
|
|
47095
|
+
* trackId → the instant THIS store closed (or deleted) the track's row.
|
|
47096
|
+
*
|
|
47097
|
+
* The `active` map alone cannot answer "is this row still open?": a sighting
|
|
47098
|
+
* for an already-closed id re-creates a fresh active entry (`upsert`), which
|
|
47099
|
+
* is exactly how a person track closed 4.4 minutes earlier received a dog's
|
|
47100
|
+
* `thumbnail`, `keyFrame` and `lastFrame` on 2026-08-21. This is POSITIVE
|
|
47101
|
+
* knowledge of a close the store performed — an id it has never heard of, or
|
|
47102
|
+
* one evicted by the bound below, reads as WRITABLE (D49): losing a live
|
|
47103
|
+
* track's media is worse than tolerating a late write.
|
|
47104
|
+
*
|
|
47105
|
+
* Insertion-ordered and bounded by `closedRowMemory` (oldest evicted first).
|
|
47106
|
+
*/
|
|
47107
|
+
closedRows = /* @__PURE__ */ new Map();
|
|
46543
47108
|
config;
|
|
46544
47109
|
logger;
|
|
46545
47110
|
store;
|
|
@@ -46561,6 +47126,25 @@ var TrackStore = class {
|
|
|
46561
47126
|
indexes: [...TRACKS_INDEXES]
|
|
46562
47127
|
});
|
|
46563
47128
|
}
|
|
47129
|
+
/**
|
|
47130
|
+
* When this store CLOSED the track's row (TTL expiry, early close, or the
|
|
47131
|
+
* cascade delete), or `undefined` when it holds no such record.
|
|
47132
|
+
*
|
|
47133
|
+
* `undefined` is not "still open" — it is "no evidence of a close", and every
|
|
47134
|
+
* caller must treat it as permission to write (D49). Read by the frame path's
|
|
47135
|
+
* best-media gate; in-memory and infallible by construction.
|
|
47136
|
+
*/
|
|
47137
|
+
rowClosedAt(trackId) {
|
|
47138
|
+
return this.closedRows.get(trackId);
|
|
47139
|
+
}
|
|
47140
|
+
/** Remember a close. Bounded, oldest-first — an evicted entry fails OPEN. */
|
|
47141
|
+
noteRowClosed(trackId, atMs) {
|
|
47142
|
+
this.closedRows.delete(trackId);
|
|
47143
|
+
this.closedRows.set(trackId, atMs);
|
|
47144
|
+
if (this.closedRows.size <= this.config.closedRowMemory) return;
|
|
47145
|
+
const oldest = this.closedRows.keys().next();
|
|
47146
|
+
if (!oldest.done) this.closedRows.delete(oldest.value);
|
|
47147
|
+
}
|
|
46564
47148
|
/** Create or update the track record for a sighting in this frame. */
|
|
46565
47149
|
upsert(params) {
|
|
46566
47150
|
const existing = this.active.get(params.trackId);
|
|
@@ -46759,6 +47343,7 @@ var TrackStore = class {
|
|
|
46759
47343
|
});
|
|
46760
47344
|
}
|
|
46761
47345
|
this.active.delete(trackId);
|
|
47346
|
+
this.noteRowClosed(trackId, nowMs);
|
|
46762
47347
|
expired.push(record);
|
|
46763
47348
|
}
|
|
46764
47349
|
return expired;
|
|
@@ -47126,6 +47711,7 @@ var TrackStore = class {
|
|
|
47126
47711
|
}
|
|
47127
47712
|
clearAll() {
|
|
47128
47713
|
this.active.clear();
|
|
47714
|
+
this.closedRows.clear();
|
|
47129
47715
|
}
|
|
47130
47716
|
/**
|
|
47131
47717
|
* Drop a single active track WITHOUT persisting it as a historical row. Used
|
|
@@ -47173,6 +47759,7 @@ var TrackStore = class {
|
|
|
47173
47759
|
const record = cloneTrack(t);
|
|
47174
47760
|
await this.persistCompleted(record);
|
|
47175
47761
|
this.active.delete(trackId);
|
|
47762
|
+
this.noteRowClosed(trackId, Date.now());
|
|
47176
47763
|
return true;
|
|
47177
47764
|
}
|
|
47178
47765
|
/** Delete the persisted track row (keyed by trackId) and drop the in-RAM
|
|
@@ -47183,6 +47770,7 @@ var TrackStore = class {
|
|
|
47183
47770
|
key: trackId
|
|
47184
47771
|
});
|
|
47185
47772
|
this.active.delete(trackId);
|
|
47773
|
+
this.noteRowClosed(trackId, Date.now());
|
|
47186
47774
|
}
|
|
47187
47775
|
/**
|
|
47188
47776
|
* A page of persisted track ids for a device whose `lastSeen < cutoffMs`
|
|
@@ -52512,13 +53100,38 @@ function retagRetentionSection(sections) {
|
|
|
52512
53100
|
} : s);
|
|
52513
53101
|
}
|
|
52514
53102
|
/**
|
|
53103
|
+
* Key of the read-only readout that replaced the `suggestionMinCosine` knob
|
|
53104
|
+
* (D222).
|
|
53105
|
+
*
|
|
53106
|
+
* It is `readonlyField: true`, so the form renders it display-only with no
|
|
53107
|
+
* mutation handler and it can never enter a settings patch — it carries a
|
|
53108
|
+
* derived value and must not become the second stored authority it replaced.
|
|
53109
|
+
* Its value is injected at hydrate time by the addon's `getGlobalSettings`
|
|
53110
|
+
* override, the same mechanism `detection-pipeline` uses for its live
|
|
53111
|
+
* model-substitution readout.
|
|
53112
|
+
*/
|
|
53113
|
+
var SUGGESTION_THRESHOLD_READOUT_KEY = "suggestionThresholdReadout";
|
|
53114
|
+
/** Percent, at most one decimal, without a trailing `.0`. Cosines here are
|
|
53115
|
+
* two-decimal operator values; `36.0%` reads like false precision. */
|
|
53116
|
+
function asPercent(value) {
|
|
53117
|
+
return `${(value * 100).toFixed(1).replace(/\.0$/, "")}%`;
|
|
53118
|
+
}
|
|
53119
|
+
/**
|
|
53120
|
+
* The sentence beside the similarity threshold: what is being suggested from,
|
|
53121
|
+
* and the rule it comes from. Both, always — a number that moves whenever
|
|
53122
|
+
* another number moves reads as a bug unless the rule is on screen with it.
|
|
53123
|
+
*/
|
|
53124
|
+
function formatSuggestionThresholdReadout(similarityThreshold) {
|
|
53125
|
+
return `${asPercent(deriveSuggestionMinCosine(similarityThreshold))} — ${asPercent(SUGGESTION_BAND_FACTOR)} of the ${asPercent(similarityThreshold)} match threshold`;
|
|
53126
|
+
}
|
|
53127
|
+
/**
|
|
52515
53128
|
* Fields that live ONLY on the global settings page and must never surface in a
|
|
52516
53129
|
* per-device contribution. The face-recognition `enabled` switch is the GLOBAL
|
|
52517
53130
|
* master kill for the whole subsystem — per-camera face production is governed
|
|
52518
53131
|
* by each device's detection-pipeline steps, so a per-device toggle would be
|
|
52519
53132
|
* misleading. Keyed by section id → set of field keys to drop.
|
|
52520
53133
|
*/
|
|
52521
|
-
var GLOBAL_ONLY_FIELDS = { "face-recognition": new Set(["enabled"]) };
|
|
53134
|
+
var GLOBAL_ONLY_FIELDS = { "face-recognition": new Set(["enabled", SUGGESTION_THRESHOLD_READOUT_KEY]) };
|
|
52522
53135
|
/** Whole sections that are cluster-wide (global page only) and must NOT appear
|
|
52523
53136
|
* in a per-device contribution. */
|
|
52524
53137
|
var GLOBAL_ONLY_SECTIONS = new Set(["cluster-post-processing"]);
|
|
@@ -53000,7 +53613,7 @@ function buildGlobalSettingsSchema() {
|
|
|
53000
53613
|
type: "number",
|
|
53001
53614
|
key: "similarityThreshold",
|
|
53002
53615
|
label: "Similarity threshold",
|
|
53003
|
-
description: "Cosine similarity (0–1) required to match a face to a known identity. Higher = stricter.",
|
|
53616
|
+
description: "Cosine similarity (0–1) required to match a face to a known identity. Higher = stricter. This is the ONLY face-match bar: the suggestion band below it is derived from this value (80% of it), so it moves with every change you make here and can never end up above it.",
|
|
53004
53617
|
min: 0,
|
|
53005
53618
|
max: 1,
|
|
53006
53619
|
step: .05,
|
|
@@ -53047,14 +53660,12 @@ function buildGlobalSettingsSchema() {
|
|
|
53047
53660
|
unit: "px"
|
|
53048
53661
|
},
|
|
53049
53662
|
{
|
|
53050
|
-
type: "
|
|
53051
|
-
key:
|
|
53052
|
-
label: "Suggestion threshold",
|
|
53053
|
-
description: "
|
|
53054
|
-
|
|
53055
|
-
|
|
53056
|
-
step: .05,
|
|
53057
|
-
default: FACE_DEFAULTS.suggestionMinCosine
|
|
53663
|
+
type: "text",
|
|
53664
|
+
key: SUGGESTION_THRESHOLD_READOUT_KEY,
|
|
53665
|
+
label: "Suggestion threshold (derived)",
|
|
53666
|
+
description: "Read-only. A plausible match that misses auto-assignment — a cosine between this value and the similarity threshold with a large-enough face, or above the similarity threshold but below the recognition size floor — is surfaced as a one-tap SUGGESTION instead of being assigned; the face stays unassigned. This floor is always 80% of the similarity threshold above, so lowering the threshold widens what is offered instead of silently closing the band.",
|
|
53667
|
+
readonlyField: true,
|
|
53668
|
+
default: ""
|
|
53058
53669
|
},
|
|
53059
53670
|
{
|
|
53060
53671
|
type: "number",
|
|
@@ -54537,7 +55148,8 @@ var customActions = {
|
|
|
54537
55148
|
...debugActions,
|
|
54538
55149
|
...orphanAuditActions,
|
|
54539
55150
|
...viewerSettingsActions,
|
|
54540
|
-
...photoEnrollActions
|
|
55151
|
+
...photoEnrollActions,
|
|
55152
|
+
...faceRescoreActions
|
|
54541
55153
|
};
|
|
54542
55154
|
/**
|
|
54543
55155
|
* Assist threshold when the caller does not pin one. Packages have their own
|
|
@@ -54726,7 +55338,7 @@ var MOTIONLESS_MAX_PX = 8;
|
|
|
54726
55338
|
var PHANTOM_CELL_PX = 32;
|
|
54727
55339
|
/** How long a cell remembers its closes. */
|
|
54728
55340
|
var PHANTOM_CELL_WINDOW_MS = 360 * 6e4;
|
|
54729
|
-
var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
55341
|
+
var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.BaseAddon {
|
|
54730
55342
|
/** Recent SHORT+MOTIONLESS track closes per `<device>:<class>:<cell>` —
|
|
54731
55343
|
* see {@link noteShortMotionlessTrack}. Measurement only; each entry is
|
|
54732
55344
|
* filtered against the 6-hour window on write, so it stays bounded. */
|
|
@@ -55258,6 +55870,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
55258
55870
|
* queued captures via the onClose hook) and `closeDevice` finally clears
|
|
55259
55871
|
* per-track state on device removal (the two documented leaks). */
|
|
55260
55872
|
residents = new TrackResidentState({ onClose: (trackId) => this.captureScheduler.cancelForTrack(trackId) });
|
|
55873
|
+
/** Defence in depth for the 2026-08-21 `b50e09f7` incident: a row the track
|
|
55874
|
+
* store has already CLOSED never earns another media target, whatever the
|
|
55875
|
+
* tracker keeps handing back. Reads the store's in-RAM close record only —
|
|
55876
|
+
* see `pipeline/closed-row-media-gate.ts` for why it can only fail OPEN. */
|
|
55877
|
+
closedRowMediaGate = new ClosedRowMediaGate();
|
|
55261
55878
|
/** Windowed per-device aggregation of the "media capture" diagnostic — the
|
|
55262
55879
|
* per-capture line is debug; a 60s per-counter SUM lands at info (~1
|
|
55263
55880
|
* line/min/device instead of one per §5 cadence tick, see media-capture-log.ts). */
|
|
@@ -55595,13 +56212,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
55595
56212
|
...debugActions,
|
|
55596
56213
|
...orphanAuditActions,
|
|
55597
56214
|
...viewerSettingsActions,
|
|
55598
|
-
...photoEnrollActions
|
|
56215
|
+
...photoEnrollActions,
|
|
56216
|
+
...faceRescoreActions
|
|
55599
56217
|
},
|
|
55600
56218
|
actionHandlers: {
|
|
55601
56219
|
...ncHandlers,
|
|
55602
56220
|
...this.buildEmbeddingActionHandlers(),
|
|
55603
56221
|
...this.buildDebugActionHandlers(),
|
|
55604
56222
|
...this.buildPhotoEnrollActionHandlers(),
|
|
56223
|
+
...this.buildFaceRescoreActionHandlers(),
|
|
55605
56224
|
...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
|
|
55606
56225
|
}
|
|
55607
56226
|
} : {}
|
|
@@ -56473,6 +57092,21 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
56473
57092
|
});
|
|
56474
57093
|
},
|
|
56475
57094
|
getMediaForOwner: (ownerKind, ownerId) => stores.mediaStore.listByOwner(ownerKind, ownerId),
|
|
57095
|
+
getSubjectTrail: async (deviceId, trackId) => {
|
|
57096
|
+
const dims = this.lastFrameDimsByDevice.get(deviceId);
|
|
57097
|
+
if (dims === void 0 || dims.w <= 0 || dims.h <= 0) return [];
|
|
57098
|
+
const track = stores.trackStore.getActiveByTrack(trackId) ?? await stores.trackStore.getPersistedByTrackId(trackId);
|
|
57099
|
+
if (track === null || track === void 0) return [];
|
|
57100
|
+
return track.positions.map((p) => ({
|
|
57101
|
+
timestamp: p.timestamp,
|
|
57102
|
+
bbox: {
|
|
57103
|
+
x: p.bbox.x / dims.w,
|
|
57104
|
+
y: p.bbox.y / dims.h,
|
|
57105
|
+
w: p.bbox.w / dims.w,
|
|
57106
|
+
h: p.bbox.h / dims.h
|
|
57107
|
+
}
|
|
57108
|
+
}));
|
|
57109
|
+
},
|
|
56476
57110
|
getDeviceName,
|
|
56477
57111
|
buildActions: (input) => this.notificationCenter?.mintButtons(input) ?? Promise.resolve([])
|
|
56478
57112
|
},
|
|
@@ -59150,6 +59784,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
59150
59784
|
const targets = [];
|
|
59151
59785
|
for (const t of tracked) {
|
|
59152
59786
|
if (t.matchedThisFrame === false) continue;
|
|
59787
|
+
const verdict = this.closedRowMediaGate.evaluate(t.trackId, this.trackStore.rowClosedAt(t.trackId), Date.now());
|
|
59788
|
+
if (verdict.refuse) {
|
|
59789
|
+
if (verdict.log) this.ctx.logger.warn("track media refused: row already closed", {
|
|
59790
|
+
tags: { deviceId },
|
|
59791
|
+
meta: {
|
|
59792
|
+
trackId: t.trackId,
|
|
59793
|
+
className: t.className,
|
|
59794
|
+
closedAgeMs: verdict.closedAgeMs,
|
|
59795
|
+
suppressed: verdict.suppressed
|
|
59796
|
+
}
|
|
59797
|
+
});
|
|
59798
|
+
continue;
|
|
59799
|
+
}
|
|
59153
59800
|
const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
|
|
59154
59801
|
const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
|
|
59155
59802
|
lastSnapshotAt: lastSnap,
|
|
@@ -59779,6 +60426,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
59779
60426
|
try {
|
|
59780
60427
|
const deviceIds = await this.retentionDeviceIds();
|
|
59781
60428
|
await this.migrateRetiredRetentionSettings(deviceIds);
|
|
60429
|
+
await this.purgeStoredSuggestionThreshold(deviceIds);
|
|
59782
60430
|
await this.runTrackRetentionSweep(deviceIds, now);
|
|
59783
60431
|
const eventStore = this.eventStore;
|
|
59784
60432
|
const sensorEventStore = this.sensorEventStore;
|
|
@@ -59898,6 +60546,96 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
59898
60546
|
}
|
|
59899
60547
|
}
|
|
59900
60548
|
}
|
|
60549
|
+
/** Whether the GLOBAL store has been checked for a stored suggestion floor
|
|
60550
|
+
* this process, and which devices have. One announcement, not a heartbeat. */
|
|
60551
|
+
storedSuggestionCheckedGlobal = false;
|
|
60552
|
+
storedSuggestionCheckedDevices = /* @__PURE__ */ new Set();
|
|
60553
|
+
/**
|
|
60554
|
+
* One-time: announce the suggestion floor D222 retired, then unset it —
|
|
60555
|
+
* globally and per device.
|
|
60556
|
+
*
|
|
60557
|
+
* Removing the field from the schema already makes a stored value inert
|
|
60558
|
+
* (`resolveFaceSettings` reads declared fields only). That is not enough. A
|
|
60559
|
+
* number sitting in the blob is an authority waiting to be re-adopted by the
|
|
60560
|
+
* next reader who greps for it, and it is what the operator last typed — so
|
|
60561
|
+
* it must leave the store AND be said out loud, because the value in force
|
|
60562
|
+
* changes: this hub carried `suggestionMinCosine: 0.33` against a 0.45
|
|
60563
|
+
* threshold, and the band is 0.36 from now on. Silence here would be the
|
|
60564
|
+
* same defect one layer up — an operator discovering three weeks later that
|
|
60565
|
+
* their number stopped applying.
|
|
60566
|
+
*
|
|
60567
|
+
* Absence of the key IS the marker: no flag, no version row, re-running is a
|
|
60568
|
+
* no-op. The write goes through `updateGlobalSettings` / `writeDeviceStore`
|
|
60569
|
+
* (never a raw store write), and the patch unsets with `undefined` — an
|
|
60570
|
+
* explicit `null` would survive the merge as exactly the residue being
|
|
60571
|
+
* removed.
|
|
60572
|
+
*/
|
|
60573
|
+
async purgeStoredSuggestionThreshold(deviceIds) {
|
|
60574
|
+
const settings = this.ctxIfReady?.settings;
|
|
60575
|
+
if (!settings) return;
|
|
60576
|
+
if (!this.storedSuggestionCheckedGlobal) {
|
|
60577
|
+
this.storedSuggestionCheckedGlobal = true;
|
|
60578
|
+
try {
|
|
60579
|
+
const plan = planStoredSuggestionPurge(await this.resolveGlobalStore());
|
|
60580
|
+
if (plan !== null) {
|
|
60581
|
+
const global = resolveFaceSettings(await this.resolveGlobalStore());
|
|
60582
|
+
this.announceRetiredSuggestionThreshold({
|
|
60583
|
+
scope: "global",
|
|
60584
|
+
was: plan.was,
|
|
60585
|
+
threshold: global.similarityThreshold
|
|
60586
|
+
});
|
|
60587
|
+
await this.updateGlobalSettings(plan.patch);
|
|
60588
|
+
}
|
|
60589
|
+
} catch (err) {
|
|
60590
|
+
this.storedSuggestionCheckedGlobal = false;
|
|
60591
|
+
this.ctx.logger.warn("face suggestion threshold purge failed — will retry", { meta: {
|
|
60592
|
+
scope: "global",
|
|
60593
|
+
error: String(err)
|
|
60594
|
+
} });
|
|
60595
|
+
}
|
|
60596
|
+
}
|
|
60597
|
+
for (const deviceId of deviceIds) {
|
|
60598
|
+
if (this.storedSuggestionCheckedDevices.has(deviceId)) continue;
|
|
60599
|
+
this.storedSuggestionCheckedDevices.add(deviceId);
|
|
60600
|
+
try {
|
|
60601
|
+
const plan = planStoredSuggestionPurge(await settings.readDeviceStore(deviceId));
|
|
60602
|
+
if (plan === null) continue;
|
|
60603
|
+
const effective = await this.resolveDeviceFaceSettings(deviceId);
|
|
60604
|
+
this.announceRetiredSuggestionThreshold({
|
|
60605
|
+
scope: "device",
|
|
60606
|
+
was: plan.was,
|
|
60607
|
+
threshold: effective.similarityThreshold,
|
|
60608
|
+
deviceId
|
|
60609
|
+
});
|
|
60610
|
+
await settings.writeDeviceStore(deviceId, plan.patch);
|
|
60611
|
+
} catch (err) {
|
|
60612
|
+
this.storedSuggestionCheckedDevices.delete(deviceId);
|
|
60613
|
+
this.ctx.logger.warn("face suggestion threshold purge failed — will retry", {
|
|
60614
|
+
tags: { deviceId },
|
|
60615
|
+
meta: {
|
|
60616
|
+
scope: "device",
|
|
60617
|
+
error: String(err)
|
|
60618
|
+
}
|
|
60619
|
+
});
|
|
60620
|
+
}
|
|
60621
|
+
}
|
|
60622
|
+
}
|
|
60623
|
+
/** The one line the operator can read to see what their number became. Both
|
|
60624
|
+
* numbers, and the rule between them, or it does not explain anything. */
|
|
60625
|
+
announceRetiredSuggestionThreshold(found) {
|
|
60626
|
+
this.ctx.logger.warn("face suggestion threshold RETIRED — it is derived from the match threshold now", {
|
|
60627
|
+
...found.deviceId !== void 0 ? { tags: { deviceId: found.deviceId } } : {},
|
|
60628
|
+
meta: {
|
|
60629
|
+
scope: found.scope,
|
|
60630
|
+
setting: STORED_SUGGESTION_MIN_COSINE_KEY,
|
|
60631
|
+
wasSetTo: found.was,
|
|
60632
|
+
similarityThreshold: found.threshold,
|
|
60633
|
+
bandFactor: SUGGESTION_BAND_FACTOR,
|
|
60634
|
+
nowInForce: deriveSuggestionMinCosine(found.threshold),
|
|
60635
|
+
why: "two knobs over one band could disagree, and did — a 0.45 threshold against a 0.50 floor made the suggestion band [0.50, 0.45) empty, so nothing could be suggested at all (D222)."
|
|
60636
|
+
}
|
|
60637
|
+
});
|
|
60638
|
+
}
|
|
59901
60639
|
/**
|
|
59902
60640
|
* Hold the unassigned face + plate buffers to their per-device capacity.
|
|
59903
60641
|
*
|
|
@@ -61404,6 +62142,205 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
61404
62142
|
byIdentity: [...progress.byIdentity]
|
|
61405
62143
|
};
|
|
61406
62144
|
}
|
|
62145
|
+
/**
|
|
62146
|
+
* Side of the canonical ArcFace aligned template, in pixels
|
|
62147
|
+
* (`ARCFACE_TEMPLATE_112` / `face-align.ts` on the runner). A stored face crop
|
|
62148
|
+
* of exactly this size IS the template the embedder was fed; any other size
|
|
62149
|
+
* is a padded bbox cut that only looks like one.
|
|
62150
|
+
*/
|
|
62151
|
+
static ARCFACE_TEMPLATE_PX = 112;
|
|
62152
|
+
/**
|
|
62153
|
+
* Re-embed ONE buffered face from its stored crop, into the cluster's current
|
|
62154
|
+
* `face-embedding` space, and persist the result on the face row.
|
|
62155
|
+
*
|
|
62156
|
+
* Persisting is not optional. `assignFace` enrols `face.embedding` verbatim,
|
|
62157
|
+
* so a rescore that computed a fresh vector, showed the operator a convincing
|
|
62158
|
+
* percentage and then threw the vector away would enrol the STALE one the
|
|
62159
|
+
* moment they acted on it — the gallery would gain a sample in a space the
|
|
62160
|
+
* matcher cannot reach, which is precisely the failure the whole re-embed
|
|
62161
|
+
* pass exists to undo.
|
|
62162
|
+
*
|
|
62163
|
+
* The 112×112 check is the load-bearing guard. A face's crop is the ArcFace
|
|
62164
|
+
* template only when the detection carried landmarks; otherwise it is a
|
|
62165
|
+
* padded bbox cut, and `face-embedding` over THAT as a whole frame returns a
|
|
62166
|
+
* vector that passes every gate and means nothing (`face-reembed-pass.ts`
|
|
62167
|
+
* documents the mirror-image trap). Refusing beats answering.
|
|
62168
|
+
*/
|
|
62169
|
+
async reembedBufferedFace(face, clusterModelId) {
|
|
62170
|
+
const api = this.ctx.api;
|
|
62171
|
+
const faceStore = this.faceStore;
|
|
62172
|
+
const mediaStore = this.mediaStore;
|
|
62173
|
+
const refuse = (reason, detail) => {
|
|
62174
|
+
this.ctx.logger.warn("face rescore: re-embed refused", {
|
|
62175
|
+
tags: { deviceId: face.deviceId },
|
|
62176
|
+
meta: {
|
|
62177
|
+
faceId: face.id,
|
|
62178
|
+
trackId: face.trackId,
|
|
62179
|
+
reason,
|
|
62180
|
+
detail,
|
|
62181
|
+
clusterModelId
|
|
62182
|
+
}
|
|
62183
|
+
});
|
|
62184
|
+
return {
|
|
62185
|
+
report: {
|
|
62186
|
+
attempted: true,
|
|
62187
|
+
succeeded: false,
|
|
62188
|
+
reason,
|
|
62189
|
+
detail,
|
|
62190
|
+
modelId: clusterModelId
|
|
62191
|
+
},
|
|
62192
|
+
embedding: null
|
|
62193
|
+
};
|
|
62194
|
+
};
|
|
62195
|
+
if (!api || !faceStore || !mediaStore) return refuse("failed", "the addon API or the face/media stores are not ready");
|
|
62196
|
+
if (face.mediaKey === void 0 || face.mediaKey === "") return refuse("no-pixels", "this face kept no crop — there is nothing to re-embed from");
|
|
62197
|
+
const file = await mediaStore.getByKey(face.mediaKey);
|
|
62198
|
+
if (file === null) return refuse("no-pixels", `the crop blob ${face.mediaKey} is gone`);
|
|
62199
|
+
const raster = await decodeJpegToRgb(file.base64);
|
|
62200
|
+
const templatePx = PipelineAnalyticsAddon.ARCFACE_TEMPLATE_PX;
|
|
62201
|
+
if (raster.width !== templatePx || raster.height !== templatePx) return refuse("not-a-template", `the stored crop is ${raster.width}×${raster.height}, not the ${templatePx}² aligned template — this face was captured without landmarks, so re-embedding it would produce a plausible and meaningless vector`);
|
|
62202
|
+
const outcome = await new FaceReembedEmbedder({
|
|
62203
|
+
runStatelessStep: (nodeId, stepInput) => this.runRebuildStepOn(nodeId, stepInput),
|
|
62204
|
+
logger: { warn: (m, e) => this.ctx.logger.warn(m, e) }
|
|
62205
|
+
}, await this.resolveRebuildNodes(api, void 0), clusterModelId).embed({
|
|
62206
|
+
sampleId: face.id,
|
|
62207
|
+
identityId: face.recognizedIdentityId ?? "",
|
|
62208
|
+
identityName: `track ${face.trackId}`,
|
|
62209
|
+
modelId: face.embeddingModelId ?? "arcface-r100",
|
|
62210
|
+
dim: face.embedding.length,
|
|
62211
|
+
mediaKey: face.mediaKey
|
|
62212
|
+
}, Buffer.from(file.base64, "base64"));
|
|
62213
|
+
if (outcome.kind === "no-capable-node") return refuse("no-capable-node", outcome.reason);
|
|
62214
|
+
if (outcome.kind === "failed") return refuse("failed", outcome.reason);
|
|
62215
|
+
await faceStore.update(face.id, {
|
|
62216
|
+
embedding: outcome.embedding,
|
|
62217
|
+
embeddingModelId: clusterModelId
|
|
62218
|
+
});
|
|
62219
|
+
if ((await faceStore.get(face.id))?.embeddingModelId !== clusterModelId) return refuse("not-persisted", "the re-embedded vector did not land on the face row");
|
|
62220
|
+
this.ctx.logger.info("face rescore: re-embedded into the cluster model space", {
|
|
62221
|
+
tags: { deviceId: face.deviceId },
|
|
62222
|
+
meta: {
|
|
62223
|
+
faceId: face.id,
|
|
62224
|
+
trackId: face.trackId,
|
|
62225
|
+
from: face.embeddingModelId ?? "arcface-r100",
|
|
62226
|
+
to: clusterModelId,
|
|
62227
|
+
dim: outcome.dim
|
|
62228
|
+
}
|
|
62229
|
+
});
|
|
62230
|
+
return {
|
|
62231
|
+
report: {
|
|
62232
|
+
attempted: true,
|
|
62233
|
+
succeeded: true,
|
|
62234
|
+
modelId: clusterModelId
|
|
62235
|
+
},
|
|
62236
|
+
embedding: outcome.embedding
|
|
62237
|
+
};
|
|
62238
|
+
}
|
|
62239
|
+
/**
|
|
62240
|
+
* The matcher parameters in force for one device, as the report echoes them.
|
|
62241
|
+
*
|
|
62242
|
+
* The suggestion floor is DERIVED here and nowhere else in this class
|
|
62243
|
+
* (D222) — and it is reported WITH `suggestionBandFactor`, because a floor
|
|
62244
|
+
* that moves whenever the threshold moves reads as a bug unless the rule is
|
|
62245
|
+
* on screen beside it. There is no stored `suggestionMinCosine` to report:
|
|
62246
|
+
* the one the live hub carried (0.33) decided nothing from the moment this
|
|
62247
|
+
* shipped, and the retention pass removes it.
|
|
62248
|
+
*/
|
|
62249
|
+
faceRescoreParams(settings) {
|
|
62250
|
+
return {
|
|
62251
|
+
threshold: settings.similarityThreshold,
|
|
62252
|
+
margin: settings.margin,
|
|
62253
|
+
minIdentitySamples: settings.minIdentitySamples,
|
|
62254
|
+
suggestionMinCosine: deriveSuggestionMinCosine(settings.similarityThreshold),
|
|
62255
|
+
suggestionBandFactor: SUGGESTION_BAND_FACTOR
|
|
62256
|
+
};
|
|
62257
|
+
}
|
|
62258
|
+
/**
|
|
62259
|
+
* `face.rescoreTrack` — the operator's "what does the gallery say NOW?".
|
|
62260
|
+
*
|
|
62261
|
+
* See `face-rescore.ts` for why the number already on the track cannot answer
|
|
62262
|
+
* that: it was computed during live detection and persisted at track close,
|
|
62263
|
+
* so every enrolment made since is invisible to it.
|
|
62264
|
+
*/
|
|
62265
|
+
async rescoreFaceForTrack(input) {
|
|
62266
|
+
const api = this.ctx.api;
|
|
62267
|
+
if (!api) throw new Error("face.rescoreTrack: the addon API is not available");
|
|
62268
|
+
const faceStore = this.faceStore;
|
|
62269
|
+
if (!faceStore) throw new Error("face.rescoreTrack: the face store is not ready");
|
|
62270
|
+
const identityStore = this.identityStore;
|
|
62271
|
+
if (!identityStore) throw new Error("face.rescoreTrack: the identity store is not ready");
|
|
62272
|
+
const faceId = `face-${input.trackId}`;
|
|
62273
|
+
const face = await faceStore.get(faceId);
|
|
62274
|
+
if (!face) throw new Error(`face.rescoreTrack: no buffered face for track ${input.trackId}`);
|
|
62275
|
+
if (face.deviceId !== input.deviceId) throw new Error(`face.rescoreTrack: face ${faceId} belongs to device ${face.deviceId}, not ${input.deviceId}`);
|
|
62276
|
+
const clusterModelId = await resolveClusterModelPin(api, FACE_EMBEDDING_STEP_ID, { warn: (m, e) => this.ctx.logger.warn(m, e) });
|
|
62277
|
+
if (clusterModelId === null) throw new Error("face.rescoreTrack: the cluster face-embedding model could not be resolved");
|
|
62278
|
+
const settings = await this.resolveDeviceFaceSettings(input.deviceId);
|
|
62279
|
+
const params = this.faceRescoreParams(settings);
|
|
62280
|
+
let probeEmbedding = face.embedding;
|
|
62281
|
+
let probeModelId = face.embeddingModelId ?? "arcface-r100";
|
|
62282
|
+
let reembed = {
|
|
62283
|
+
attempted: false,
|
|
62284
|
+
succeeded: false,
|
|
62285
|
+
reason: "not-requested"
|
|
62286
|
+
};
|
|
62287
|
+
if (input.reembed) {
|
|
62288
|
+
const outcome = await this.reembedBufferedFace(face, clusterModelId);
|
|
62289
|
+
reembed = outcome.report;
|
|
62290
|
+
if (outcome.embedding !== null) {
|
|
62291
|
+
probeEmbedding = outcome.embedding;
|
|
62292
|
+
probeModelId = clusterModelId;
|
|
62293
|
+
}
|
|
62294
|
+
}
|
|
62295
|
+
const [gallery, identities] = await Promise.all([identityStore.loadGallery(), identityStore.listIdentities()]);
|
|
62296
|
+
const report = buildFaceRescoreReport({
|
|
62297
|
+
probe: {
|
|
62298
|
+
embedding: probeEmbedding,
|
|
62299
|
+
modelId: probeModelId
|
|
62300
|
+
},
|
|
62301
|
+
gallery,
|
|
62302
|
+
identityNames: new Map(identities.map((i) => [i.id, i.name])),
|
|
62303
|
+
clusterModelId,
|
|
62304
|
+
params
|
|
62305
|
+
});
|
|
62306
|
+
this.ctx.logger.info("face rescore", {
|
|
62307
|
+
tags: { deviceId: input.deviceId },
|
|
62308
|
+
meta: {
|
|
62309
|
+
trackId: input.trackId,
|
|
62310
|
+
faceId,
|
|
62311
|
+
verdict: report.verdict,
|
|
62312
|
+
probeModelId,
|
|
62313
|
+
clusterModelId,
|
|
62314
|
+
gallerySamples: gallery.length,
|
|
62315
|
+
scored: report.identities.length,
|
|
62316
|
+
best: report.identities[0]?.name ?? null,
|
|
62317
|
+
bestScore: report.identities[0]?.score ?? null,
|
|
62318
|
+
storedSuggestion: face.suggestedMatchScore ?? null,
|
|
62319
|
+
reembedded: reembed.succeeded
|
|
62320
|
+
}
|
|
62321
|
+
});
|
|
62322
|
+
return {
|
|
62323
|
+
faceId,
|
|
62324
|
+
deviceId: face.deviceId,
|
|
62325
|
+
trackId: face.trackId,
|
|
62326
|
+
assigned: face.assigned,
|
|
62327
|
+
...face.recognizedIdentityId != null ? { assignedIdentityId: face.recognizedIdentityId } : {},
|
|
62328
|
+
...face.suggestedIdentityId != null ? { storedSuggestedIdentityId: face.suggestedIdentityId } : {},
|
|
62329
|
+
...face.suggestedMatchScore != null ? { storedSuggestedMatchScore: face.suggestedMatchScore } : {},
|
|
62330
|
+
verdict: report.verdict,
|
|
62331
|
+
comparable: report.comparable,
|
|
62332
|
+
probeModelId: report.probeModelId,
|
|
62333
|
+
clusterModelId: report.clusterModelId,
|
|
62334
|
+
params: report.params,
|
|
62335
|
+
identities: report.identities,
|
|
62336
|
+
...report.runnerUpGap !== void 0 ? { runnerUpGap: report.runnerUpGap } : {},
|
|
62337
|
+
reembedAvailable: probeModelId !== clusterModelId && face.mediaKey !== void 0 && face.mediaKey !== "",
|
|
62338
|
+
reembed
|
|
62339
|
+
};
|
|
62340
|
+
}
|
|
62341
|
+
buildFaceRescoreActionHandlers() {
|
|
62342
|
+
return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
|
|
62343
|
+
}
|
|
61407
62344
|
buildPhotoEnrollActionHandlers() {
|
|
61408
62345
|
return {
|
|
61409
62346
|
"photo.analyze": async (input) => {
|
|
@@ -62100,6 +63037,30 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
62100
63037
|
globalSettingsSchema() {
|
|
62101
63038
|
return this.schema(buildGlobalSettingsSchema());
|
|
62102
63039
|
}
|
|
63040
|
+
/**
|
|
63041
|
+
* The only reason this is overridden: the face suggestion floor is DERIVED
|
|
63042
|
+
* (D222), so the form has to be able to SHOW it without storing it.
|
|
63043
|
+
*
|
|
63044
|
+
* The schema is a static declaration and cannot interpolate a live value, so
|
|
63045
|
+
* the readout's value is injected into the hydrate blob here — the same
|
|
63046
|
+
* mechanism `detection-pipeline` uses for its model-substitution readout.
|
|
63047
|
+
* It is a `readonlyField`, so it renders display-only, never enters a patch,
|
|
63048
|
+
* and never reaches the store: the operator sees what their threshold
|
|
63049
|
+
* implies and has nothing to contradict it with. Everything else about this
|
|
63050
|
+
* call is `BaseAddon`'s.
|
|
63051
|
+
*/
|
|
63052
|
+
async getGlobalSettings(overlay, cap, nodeId) {
|
|
63053
|
+
const stored = await this.resolveGlobalStore(nodeId, cap);
|
|
63054
|
+
const merged = overlay ? {
|
|
63055
|
+
...stored,
|
|
63056
|
+
...overlay
|
|
63057
|
+
} : stored;
|
|
63058
|
+
const readout = formatSuggestionThresholdReadout(resolveFaceSettings(merged).similarityThreshold);
|
|
63059
|
+
return super.getGlobalSettings({
|
|
63060
|
+
...merged,
|
|
63061
|
+
[SUGGESTION_THRESHOLD_READOUT_KEY]: readout
|
|
63062
|
+
}, cap, nodeId);
|
|
63063
|
+
}
|
|
62103
63064
|
async getDeviceSettingsContribution(input) {
|
|
62104
63065
|
if (!await this.isCameraDevice(input.deviceId)) return null;
|
|
62105
63066
|
const schema = this.globalSettingsSchema();
|