@camstack/addon-post-analysis 1.2.95 → 1.2.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{dist-BdojoP7i.js → dist-BQsMSX2A.js} +29 -3
- package/dist/{dist-C-_TL-I1.mjs → dist-ecY_-nDx.mjs} +29 -3
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CeFPqm3h.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DyXoKV7Z.mjs} +3 -3
- package/dist/pipeline-analytics/{hostInit-BExsGl4h.mjs → hostInit-CkEakaQn.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +425 -165
- package/dist/pipeline-analytics/index.mjs +425 -165
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-BQsMSX2A.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
node_fs = require_dist.__toESM(node_fs, 1);
|
|
8
8
|
let node_path = require("node:path");
|
|
@@ -5562,24 +5562,39 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
|
5562
5562
|
}).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
|
|
5563
5563
|
}
|
|
5564
5564
|
//#endregion
|
|
5565
|
-
//#region src/notification-center/
|
|
5565
|
+
//#region src/notification-center/still-shelf.ts
|
|
5566
5566
|
/**
|
|
5567
|
-
* The
|
|
5567
|
+
* The still shelf — the PHOTOGRAPH a notification carries when nothing it can
|
|
5568
|
+
* name owns a frame.
|
|
5568
5569
|
*
|
|
5569
|
-
* ## Why a
|
|
5570
|
+
* ## Why a trigger needs one at all
|
|
5570
5571
|
*
|
|
5571
|
-
* The attachment ladder resolves media by OWNER, and
|
|
5572
|
-
*
|
|
5573
|
-
*
|
|
5572
|
+
* The attachment ladder resolves media by OWNER, and most triggers have one: an
|
|
5573
|
+
* object or package event owns its crops, a closed track owns its best shot, a
|
|
5574
|
+
* doorbell press owns the marker track the same press projected
|
|
5574
5575
|
* (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
|
|
5575
|
-
* counted (`chooseOccupancyMediaOwner`).
|
|
5576
|
-
*
|
|
5577
|
-
*
|
|
5578
|
-
*
|
|
5576
|
+
* counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
|
|
5577
|
+
* same reason in both cases — the subject is an ABSENCE:
|
|
5578
|
+
*
|
|
5579
|
+
* - an **audio** match: nothing was boxed, nothing was tracked, and —
|
|
5580
|
+
* deliberately — nothing is persisted at all. A confirmed window is a claim
|
|
5581
|
+
* about sound that has already stopped, and `event-intake.ts` states why
|
|
5582
|
+
* replaying it later would be wrong.
|
|
5583
|
+
* - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
|
|
5584
|
+
* whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
|
|
5585
|
+
* nobody and the ladder logs `no still could be resolved … owners=[]`.
|
|
5579
5586
|
*
|
|
5580
5587
|
* So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
|
|
5581
|
-
* of the
|
|
5582
|
-
*
|
|
5588
|
+
* of the trigger. Not of the sound, not of the object that left — of what the
|
|
5589
|
+
* camera can see now that it happened. For a freed parking space that is
|
|
5590
|
+
* exactly the answer the operator wants: the space, empty.
|
|
5591
|
+
*
|
|
5592
|
+
* **One shelf, not two.** The mechanism is identical down to the reuse window,
|
|
5593
|
+
* and the only thing that differs between the two triggers is the GATE deciding
|
|
5594
|
+
* that a photograph is owed at all — which belongs at the trigger site, where
|
|
5595
|
+
* the rules are, and not here. A twin module would be a second derivation of
|
|
5596
|
+
* one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
|
|
5597
|
+
* can say which absence they are about.
|
|
5583
5598
|
*
|
|
5584
5599
|
* ## Three properties, and each one is a decision
|
|
5585
5600
|
*
|
|
@@ -5587,27 +5602,30 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
|
5587
5602
|
* TTL that covers the outbox's whole retry horizon — and nowhere else. The
|
|
5588
5603
|
* alternative was the doorbell's: materialise a synthetic marker track through
|
|
5589
5604
|
* `SyntheticTrackMaterializer` and let the notification name it. That would put
|
|
5590
|
-
* a durable Track on the camera's timeline for every confirmed window
|
|
5591
|
-
* the digest's `listTracks`, retention, and the audio-
|
|
5592
|
-
* operator ceilings (`audio-marker-projector.ts` exists
|
|
5593
|
-
* many audio markers a camera may emit). A notification
|
|
5594
|
-
* timeline history as a side effect of wanting a picture.
|
|
5595
|
-
*
|
|
5596
|
-
* **The capture STARTS immediately and is never awaited.**
|
|
5597
|
-
* — a scream is over before a snapshot round-trip completes
|
|
5598
|
-
*
|
|
5599
|
-
*
|
|
5600
|
-
*
|
|
5601
|
-
*
|
|
5602
|
-
*
|
|
5603
|
-
*
|
|
5604
|
-
*
|
|
5605
|
+
* a durable Track on the camera's timeline for every confirmed window and every
|
|
5606
|
+
* emptied zone, feeding the digest's `listTracks`, retention, and the audio-
|
|
5607
|
+
* marker feature's own operator ceilings (`audio-marker-projector.ts` exists
|
|
5608
|
+
* precisely to bound how many audio markers a camera may emit). A notification
|
|
5609
|
+
* must not manufacture timeline history as a side effect of wanting a picture.
|
|
5610
|
+
*
|
|
5611
|
+
* **The capture STARTS immediately and is never awaited.** The subject is
|
|
5612
|
+
* transient — a scream is over before a snapshot round-trip completes, and a
|
|
5613
|
+
* freed space is about to be taken by the next car — so the fetch is kicked off
|
|
5614
|
+
* at the trigger, before the rule evaluation runs, and the owner id is minted
|
|
5615
|
+
* synchronously so the outbox row can name it. The bytes land while the row
|
|
5616
|
+
* waits in the queue, and the dispatcher's existing bounded still-wait (or the
|
|
5617
|
+
* pause its own footage render already costs) picks them up. A camera that
|
|
5618
|
+
* never answers costs the picture and never the notification.
|
|
5619
|
+
*
|
|
5620
|
+
* **Two triggers seconds apart share ONE capture.** A barking dog confirms
|
|
5605
5621
|
* repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
|
|
5606
5622
|
* rule's cooldown is its only brake, and the cooldown is applied AFTER this.
|
|
5607
5623
|
* Without a reuse window this would photograph a camera at whatever rate the
|
|
5608
|
-
* sound happens to occur. Each
|
|
5624
|
+
* sound happens to occur. Each trigger still gets its OWN owner id, so two
|
|
5609
5625
|
* outbox rows are never mistaken for one subject; they merely point at the same
|
|
5610
|
-
* frame, which is the truth — the scene did not change in ten seconds.
|
|
5626
|
+
* frame, which is the truth — the scene did not change in ten seconds. The
|
|
5627
|
+
* window is per CAMERA and trigger-agnostic for the same reason: a sound and an
|
|
5628
|
+
* emptied zone ten seconds apart are two claims about one scene.
|
|
5611
5629
|
*
|
|
5612
5630
|
* Nothing here is silent: a capture that lands and a camera that refuses each
|
|
5613
5631
|
* emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
|
|
@@ -5615,13 +5633,13 @@ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
|
|
|
5615
5633
|
*/
|
|
5616
5634
|
/**
|
|
5617
5635
|
* The owner-id namespace. It is what routes a lookup here instead of to the
|
|
5618
|
-
* media store, and it is the reason the dispatcher needs no audio
|
|
5619
|
-
* `getMediaForOwner` answers for
|
|
5636
|
+
* media store, and it is the reason the dispatcher needs no audio or occupancy
|
|
5637
|
+
* branch — `getMediaForOwner` answers for all of them under one signature.
|
|
5620
5638
|
*/
|
|
5621
|
-
var
|
|
5639
|
+
var NC_STILL_SHELF_PREFIX = "nc-still:";
|
|
5622
5640
|
/** True for an owner id this shelf minted. */
|
|
5623
|
-
function
|
|
5624
|
-
return id.startsWith(
|
|
5641
|
+
function isStillShelfId(id) {
|
|
5642
|
+
return id.startsWith(NC_STILL_SHELF_PREFIX);
|
|
5625
5643
|
}
|
|
5626
5644
|
/**
|
|
5627
5645
|
* How long the bytes are held.
|
|
@@ -5631,16 +5649,16 @@ function isAudioStillId(id) {
|
|
|
5631
5649
|
* ships text-only, which is the correct degradation for a photograph of a scene
|
|
5632
5650
|
* that is a quarter of an hour stale anyway.
|
|
5633
5651
|
*/
|
|
5634
|
-
var
|
|
5652
|
+
var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
|
|
5635
5653
|
/** A hanging snapshot cap must not pin a capture slot forever. */
|
|
5636
5654
|
var SNAPSHOT_TIMEOUT_MS = 8e3;
|
|
5637
5655
|
/**
|
|
5638
|
-
* Hard bound on held captures. Reached only if every camera on the hub
|
|
5639
|
-
*
|
|
5656
|
+
* Hard bound on held captures. Reached only if every camera on the hub triggers
|
|
5657
|
+
* one inside a single TTL; the oldest is dropped first, which costs a
|
|
5640
5658
|
* fifteen-minute-old picture nobody is waiting for.
|
|
5641
5659
|
*/
|
|
5642
5660
|
var MAX_CAPTURES = 64;
|
|
5643
|
-
var
|
|
5661
|
+
var NcStillShelf = class {
|
|
5644
5662
|
deps;
|
|
5645
5663
|
/** captureId → the photograph. */
|
|
5646
5664
|
captures = /* @__PURE__ */ new Map();
|
|
@@ -5652,14 +5670,14 @@ var NcAudioStillShelf = class {
|
|
|
5652
5670
|
this.deps = deps;
|
|
5653
5671
|
}
|
|
5654
5672
|
/**
|
|
5655
|
-
* Photograph `deviceId` for a
|
|
5656
|
-
* subject should name. SYNCHRONOUS by contract: the caller is on the
|
|
5657
|
-
*
|
|
5673
|
+
* Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
|
|
5674
|
+
* subject should name. SYNCHRONOUS by contract: the caller is on the trigger
|
|
5675
|
+
* path and the outbox row is built from what this returns.
|
|
5658
5676
|
*/
|
|
5659
|
-
capture(deviceId, atMs) {
|
|
5677
|
+
capture(deviceId, atMs, trigger) {
|
|
5660
5678
|
const now = this.deps.now();
|
|
5661
5679
|
this.prune(now);
|
|
5662
|
-
const ownerId = `${
|
|
5680
|
+
const ownerId = `${NC_STILL_SHELF_PREFIX}${(0, node_crypto.randomUUID)()}`;
|
|
5663
5681
|
const recent = this.newest.get(deviceId);
|
|
5664
5682
|
if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
|
|
5665
5683
|
this.owners.set(ownerId, recent.captureId);
|
|
@@ -5668,8 +5686,9 @@ var NcAudioStillShelf = class {
|
|
|
5668
5686
|
const captureId = (0, node_crypto.randomUUID)();
|
|
5669
5687
|
this.captures.set(captureId, {
|
|
5670
5688
|
deviceId,
|
|
5689
|
+
trigger,
|
|
5671
5690
|
startedAt: now,
|
|
5672
|
-
expiresAt: now +
|
|
5691
|
+
expiresAt: now + NC_STILL_SHELF_TTL_MS,
|
|
5673
5692
|
files: []
|
|
5674
5693
|
});
|
|
5675
5694
|
this.newest.set(deviceId, {
|
|
@@ -5677,7 +5696,7 @@ var NcAudioStillShelf = class {
|
|
|
5677
5696
|
at: now
|
|
5678
5697
|
});
|
|
5679
5698
|
this.owners.set(ownerId, captureId);
|
|
5680
|
-
this.fetch(captureId, deviceId, atMs);
|
|
5699
|
+
this.fetch(captureId, deviceId, atMs, trigger);
|
|
5681
5700
|
return ownerId;
|
|
5682
5701
|
}
|
|
5683
5702
|
/**
|
|
@@ -5713,11 +5732,11 @@ var NcAudioStillShelf = class {
|
|
|
5713
5732
|
this.owners.clear();
|
|
5714
5733
|
this.newest.clear();
|
|
5715
5734
|
}
|
|
5716
|
-
async fetch(captureId, deviceId, atMs) {
|
|
5735
|
+
async fetch(captureId, deviceId, atMs, trigger) {
|
|
5717
5736
|
try {
|
|
5718
5737
|
const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
|
|
5719
5738
|
if (shot === null || shot.base64.length === 0) {
|
|
5720
|
-
this.reportMiss(deviceId, "the camera returned no snapshot");
|
|
5739
|
+
this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
|
|
5721
5740
|
return;
|
|
5722
5741
|
}
|
|
5723
5742
|
const raw = Buffer.from(shot.base64, "base64");
|
|
@@ -5729,17 +5748,18 @@ var NcAudioStillShelf = class {
|
|
|
5729
5748
|
if (held === void 0) return;
|
|
5730
5749
|
this.captures.set(captureId, {
|
|
5731
5750
|
...held,
|
|
5732
|
-
files:
|
|
5751
|
+
files: stillShelfMedia(data, atMs)
|
|
5733
5752
|
});
|
|
5734
|
-
this.deps.logger.info(
|
|
5753
|
+
this.deps.logger.info(`${trigger} still captured`, {
|
|
5735
5754
|
tags: { deviceId },
|
|
5736
5755
|
meta: {
|
|
5756
|
+
trigger,
|
|
5737
5757
|
bytes: data.byteLength,
|
|
5738
5758
|
tookMs: this.deps.now() - held.startedAt
|
|
5739
5759
|
}
|
|
5740
5760
|
});
|
|
5741
5761
|
} catch (err) {
|
|
5742
|
-
this.reportMiss(deviceId, err instanceof Error ? err.message : String(err));
|
|
5762
|
+
this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
|
|
5743
5763
|
}
|
|
5744
5764
|
}
|
|
5745
5765
|
/**
|
|
@@ -5747,10 +5767,13 @@ var NcAudioStillShelf = class {
|
|
|
5747
5767
|
* on a notification they DID receive, so it is a warn and it carries the
|
|
5748
5768
|
* camera — the only key the question is ever asked with.
|
|
5749
5769
|
*/
|
|
5750
|
-
reportMiss(deviceId, reason) {
|
|
5751
|
-
this.deps.logger.warn(
|
|
5770
|
+
reportMiss(deviceId, trigger, reason) {
|
|
5771
|
+
this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
|
|
5752
5772
|
tags: { deviceId },
|
|
5753
|
-
meta: {
|
|
5773
|
+
meta: {
|
|
5774
|
+
trigger,
|
|
5775
|
+
reason
|
|
5776
|
+
}
|
|
5754
5777
|
});
|
|
5755
5778
|
}
|
|
5756
5779
|
};
|
|
@@ -5765,10 +5788,10 @@ var NcAudioStillShelf = class {
|
|
|
5765
5788
|
* so. Inventing a centre crop would answer a different question from the one
|
|
5766
5789
|
* the operator asked.
|
|
5767
5790
|
*/
|
|
5768
|
-
function
|
|
5791
|
+
function stillShelfMedia(data, timestamp) {
|
|
5769
5792
|
const base64 = data.toString("base64");
|
|
5770
5793
|
const file = (kind) => ({
|
|
5771
|
-
key: `${
|
|
5794
|
+
key: `${NC_STILL_SHELF_PREFIX}${kind}`,
|
|
5772
5795
|
kind,
|
|
5773
5796
|
base64,
|
|
5774
5797
|
sizeBytes: data.byteLength,
|
|
@@ -7434,7 +7457,7 @@ function subjectFromSensorEvent(ev, markerTrackId) {
|
|
|
7434
7457
|
* (see the {@link evaluateRule} audio gate + the matchers below).
|
|
7435
7458
|
*
|
|
7436
7459
|
* `stillOwnerId` is the audio still shelf's owner id — the photograph of the
|
|
7437
|
-
* camera taken at the match (`
|
|
7460
|
+
* camera taken at the match (`still-shelf.ts`). It rides `trackId` because that
|
|
7438
7461
|
* is the field the attachment ladder reads, exactly as a doorbell press rides
|
|
7439
7462
|
* its marker track's id; nothing else about the subject is track-scoped, and
|
|
7440
7463
|
* `buildEntries` keys an audio row on its record id, never on this. Absent when
|
|
@@ -7526,10 +7549,18 @@ function audioSubjectFor(hit, spec) {
|
|
|
7526
7549
|
* chosen at commit (`chooseOccupancyMediaOwner`) — rides on `trackId`, and that
|
|
7527
7550
|
* is the whole of what gives an occupancy notification a picture: the ordinary
|
|
7528
7551
|
* media-owner ladder resolves it, exactly as it resolves a doorbell's marker
|
|
7529
|
-
* track.
|
|
7530
|
-
*
|
|
7531
|
-
|
|
7532
|
-
|
|
7552
|
+
* track.
|
|
7553
|
+
*
|
|
7554
|
+
* `stillOwnerId` is the SECOND source of that picture and the only one an
|
|
7555
|
+
* emptied scope can have: with no member left to name — "posto libero", where
|
|
7556
|
+
* the object whose departure is the news has gone — the trigger site photographs
|
|
7557
|
+
* the camera and holds the bytes on the still shelf (`still-shelf.ts`), exactly
|
|
7558
|
+
* as an audio match does. The member track always WINS: it is a picture of the
|
|
7559
|
+
* counted object, and a photograph is only ever the stand-in for not having one.
|
|
7560
|
+
* Neither present (no snapshot cap on this node) ⇒ text-only, as before.
|
|
7561
|
+
*/
|
|
7562
|
+
function subjectFromOccupancyEvent(edge, stillOwnerId) {
|
|
7563
|
+
const mediaOwner = edge.mediaTrackId ?? stillOwnerId;
|
|
7533
7564
|
return {
|
|
7534
7565
|
kind: "occupancy-event",
|
|
7535
7566
|
recordId: `occ:${edge.deviceId}:${edge.zoneId ?? "@frame"}:${edge.className ?? "@all"}:t${edge.threshold}:${edge.occupied ? "occ" : "free"}:${edge.timestamp}`,
|
|
@@ -7537,17 +7568,28 @@ function subjectFromOccupancyEvent(edge) {
|
|
|
7537
7568
|
timestamp: edge.timestamp,
|
|
7538
7569
|
classNames: [edge.className ?? "occupancy"],
|
|
7539
7570
|
zones: [],
|
|
7540
|
-
...
|
|
7571
|
+
...mediaOwner !== void 0 ? { trackId: mediaOwner } : {},
|
|
7541
7572
|
source: "pipeline",
|
|
7542
|
-
occupancy:
|
|
7543
|
-
|
|
7544
|
-
|
|
7545
|
-
|
|
7546
|
-
|
|
7547
|
-
|
|
7548
|
-
|
|
7549
|
-
|
|
7550
|
-
|
|
7573
|
+
occupancy: occupancySubjectFromEdge(edge)
|
|
7574
|
+
};
|
|
7575
|
+
}
|
|
7576
|
+
/**
|
|
7577
|
+
* The scope + level half of an occupancy subject, ALONE.
|
|
7578
|
+
*
|
|
7579
|
+
* Split out for one reason: the still-shelf gate must ask `matchesOccupancy`
|
|
7580
|
+
* whether a rule would fire on an edge BEFORE the subject that would carry the
|
|
7581
|
+
* answer exists (the owner id it mints is an input to that subject). Sharing
|
|
7582
|
+
* this keeps the gate reading the same numbers the verdict will.
|
|
7583
|
+
*/
|
|
7584
|
+
function occupancySubjectFromEdge(edge) {
|
|
7585
|
+
return {
|
|
7586
|
+
...edge.zoneId !== void 0 ? { zoneId: edge.zoneId } : {},
|
|
7587
|
+
...edge.zoneName !== void 0 ? { zoneName: edge.zoneName } : {},
|
|
7588
|
+
...edge.className !== void 0 ? { className: edge.className } : {},
|
|
7589
|
+
count: edge.count,
|
|
7590
|
+
previousCount: edge.previousCount,
|
|
7591
|
+
occupied: edge.occupied,
|
|
7592
|
+
threshold: edge.threshold
|
|
7551
7593
|
};
|
|
7552
7594
|
}
|
|
7553
7595
|
/** Build the subject for a `package-event` evaluation (a persisted `package`
|
|
@@ -7674,6 +7716,13 @@ function matchesAudio(cond, s) {
|
|
|
7674
7716
|
}
|
|
7675
7717
|
return false;
|
|
7676
7718
|
}
|
|
7719
|
+
/**
|
|
7720
|
+
* Exported for ONE caller: the still-shelf gate in the centre, which must know
|
|
7721
|
+
* — before the outbox row exists — whether any enabled rule would fire on THIS
|
|
7722
|
+
* edge and wants a picture for it. Sharing the predicate is what keeps that gate
|
|
7723
|
+
* from becoming a second, drifting opinion about which edge belongs to which
|
|
7724
|
+
* rule. It is pure and reads nothing.
|
|
7725
|
+
*/
|
|
7677
7726
|
function matchesOccupancy(occ, s) {
|
|
7678
7727
|
if ((occ.zoneId ?? void 0) !== (s.zoneId ?? void 0)) return false;
|
|
7679
7728
|
if ((occ.className ?? void 0) !== (s.className ?? void 0)) return false;
|
|
@@ -9881,7 +9930,7 @@ function incomingFromSensorEvent(event, markerTrackId) {
|
|
|
9881
9930
|
* Audio-event persist (classified episode).
|
|
9882
9931
|
*
|
|
9883
9932
|
* `stillOwnerId` is the audio still shelf's owner id for the photograph taken
|
|
9884
|
-
* at this episode (`
|
|
9933
|
+
* at this episode (`still-shelf.ts`). Absent when no rule that could fire asked
|
|
9885
9934
|
* for a picture — the same shape `incomingFromSensorEvent` carries its marker.
|
|
9886
9935
|
*/
|
|
9887
9936
|
function incomingFromAudioEvent(event, stillOwnerId) {
|
|
@@ -9962,10 +10011,16 @@ function audioHitMeta(hit) {
|
|
|
9962
10011
|
samples: window.samples
|
|
9963
10012
|
};
|
|
9964
10013
|
}
|
|
9965
|
-
/**
|
|
9966
|
-
|
|
10014
|
+
/**
|
|
10015
|
+
* A committed ZoneAnalytics occupancy edge.
|
|
10016
|
+
*
|
|
10017
|
+
* `stillOwnerId` — see {@link subjectFromOccupancyEvent}. An edge whose scope is
|
|
10018
|
+
* empty owns no member track, so the only picture it can carry is a photograph
|
|
10019
|
+
* of its camera taken at the edge, held on the still shelf under this id.
|
|
10020
|
+
*/
|
|
10021
|
+
function incomingFromOccupancyEdge(edge, stillOwnerId) {
|
|
9967
10022
|
return {
|
|
9968
|
-
subject: subjectFromOccupancyEvent(edge),
|
|
10023
|
+
subject: subjectFromOccupancyEvent(edge, stillOwnerId),
|
|
9969
10024
|
kind: "occupancy-event",
|
|
9970
10025
|
origin: "pipeline",
|
|
9971
10026
|
log: () => ({
|
|
@@ -14151,7 +14206,11 @@ function mosaicTextPlan(captions) {
|
|
|
14151
14206
|
};
|
|
14152
14207
|
}
|
|
14153
14208
|
/** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
|
|
14154
|
-
* by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip.
|
|
14209
|
+
* by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip.
|
|
14210
|
+
*
|
|
14211
|
+
* `rows` is the load-bearing half — it is how many rows {@link mosaicRowCounts}
|
|
14212
|
+
* distributes into. `cols` describes the WIDEST row, and is no longer a cell
|
|
14213
|
+
* count: see {@link mosaicRowCounts} for why a `cols × rows` grid was the bug. */
|
|
14155
14214
|
function mosaicGrid(n) {
|
|
14156
14215
|
const count = Math.max(1, Math.floor(n));
|
|
14157
14216
|
const cols = Math.ceil(Math.sqrt(count));
|
|
@@ -14161,31 +14220,84 @@ function mosaicGrid(n) {
|
|
|
14161
14220
|
};
|
|
14162
14221
|
}
|
|
14163
14222
|
/**
|
|
14223
|
+
* How many tiles each ROW carries, top to bottom — and the reason the mosaic
|
|
14224
|
+
* can no longer have a hole in it.
|
|
14225
|
+
*
|
|
14226
|
+
* The layout used to be a `cols × rows` GRID, and a grid holds more cells than
|
|
14227
|
+
* tiles for every count that is not a perfect fit: 3 into a 2×2, 5 into a 3×2,
|
|
14228
|
+
* 7 and 8 into a 3×3. The composite only ever wrote the tiles, so the spare
|
|
14229
|
+
* cells stayed the canvas — `MOSAIC_BACKGROUND`, rgb 17 — and the operator's
|
|
14230
|
+
* three-tile digest was a quarter black. Measured on the shipped renderer:
|
|
14231
|
+
* 24.8% of a three-tile mosaic was background. His words: *"se ci sono 3
|
|
14232
|
+
* elementi non dovrebbe renderizzare buchi neri"*.
|
|
14233
|
+
*
|
|
14234
|
+
* Counting ROWS instead removes the failure by construction rather than by
|
|
14235
|
+
* patching it. Every row spans the full width and divides it among the tiles it
|
|
14236
|
+
* actually has, so a row is full whatever the remainder and there is no such
|
|
14237
|
+
* thing as a spare cell. The shapes fall out of it, and they are the ones asked
|
|
14238
|
+
* for: 1 ⇒ full frame, 2 ⇒ side by side, 3 ⇒ one big over two small, 4 ⇒ 2×2.
|
|
14239
|
+
*
|
|
14240
|
+
* **The fewer-tiled row comes FIRST.** Tiles arrive in the selection's order,
|
|
14241
|
+
* so row 0 is the head of the ranking; putting the remainder at the top would
|
|
14242
|
+
* make the WORST tile the big one.
|
|
14243
|
+
*
|
|
14244
|
+
* Every cell keeps {@link TILE_ASPECT}, so rows differ in height — which is
|
|
14245
|
+
* also what stops the letterbox: a full-width hero tile is 16:9 exactly, not a
|
|
14246
|
+
* 16:9 picture centred in a double-height cell with bars over and under it.
|
|
14247
|
+
*/
|
|
14248
|
+
function mosaicRowCounts(n) {
|
|
14249
|
+
const count = Math.max(1, Math.floor(n));
|
|
14250
|
+
const { rows } = mosaicGrid(count);
|
|
14251
|
+
const base = Math.floor(count / rows);
|
|
14252
|
+
const remainder = count % rows;
|
|
14253
|
+
const counts = [];
|
|
14254
|
+
for (let row = 0; row < rows; row += 1) counts.push(row < rows - remainder ? base : base + 1);
|
|
14255
|
+
return counts;
|
|
14256
|
+
}
|
|
14257
|
+
/**
|
|
14164
14258
|
* Where every tile lands. Pure — no bytes, no sharp, no clock.
|
|
14165
14259
|
*
|
|
14166
14260
|
* Exported because the rectangles are DATA the artefact should carry: a viewer
|
|
14167
14261
|
* that wants a tappable mosaic reads these rather than re-deriving a layout
|
|
14168
14262
|
* that could drift from the one actually rendered.
|
|
14263
|
+
*
|
|
14264
|
+
* The rectangles COVER the picture area exactly — that is the property the
|
|
14265
|
+
* spec asserts, and it is the one the old grid broke.
|
|
14169
14266
|
*/
|
|
14170
14267
|
function mosaicLayout(input) {
|
|
14171
|
-
const
|
|
14172
|
-
const
|
|
14173
|
-
const
|
|
14268
|
+
const rowCounts = mosaicRowCounts(input.tileCount);
|
|
14269
|
+
const width = Math.max(1, Math.floor(input.maxWidth ?? 1920));
|
|
14270
|
+
const firstRowCount = rowCounts[0] ?? 1;
|
|
14271
|
+
const tileWidth = Math.floor(width / firstRowCount);
|
|
14174
14272
|
const tileHeight = Math.round(tileWidth * TILE_ASPECT);
|
|
14175
14273
|
const titleHeight = input.withTitle === true ? Math.round(tileHeight * TITLE_BAND_FRACTION) : 0;
|
|
14176
14274
|
const tiles = [];
|
|
14177
|
-
|
|
14178
|
-
|
|
14179
|
-
|
|
14180
|
-
|
|
14181
|
-
|
|
14182
|
-
|
|
14183
|
-
|
|
14275
|
+
let y = titleHeight;
|
|
14276
|
+
let index = 0;
|
|
14277
|
+
for (const count of rowCounts) {
|
|
14278
|
+
const nominalWidth = Math.floor(width / count);
|
|
14279
|
+
const rowHeight = Math.round(nominalWidth * TILE_ASPECT);
|
|
14280
|
+
let x = 0;
|
|
14281
|
+
for (let inRow = 0; inRow < count; inRow += 1) {
|
|
14282
|
+
const w = inRow === count - 1 ? width - x : nominalWidth;
|
|
14283
|
+
tiles.push({
|
|
14284
|
+
index,
|
|
14285
|
+
x,
|
|
14286
|
+
y,
|
|
14287
|
+
w,
|
|
14288
|
+
h: rowHeight
|
|
14289
|
+
});
|
|
14290
|
+
x += w;
|
|
14291
|
+
index += 1;
|
|
14292
|
+
}
|
|
14293
|
+
y += rowHeight;
|
|
14294
|
+
}
|
|
14184
14295
|
return {
|
|
14185
|
-
width
|
|
14186
|
-
height:
|
|
14187
|
-
cols,
|
|
14188
|
-
rows,
|
|
14296
|
+
width,
|
|
14297
|
+
height: y,
|
|
14298
|
+
cols: Math.max(...rowCounts),
|
|
14299
|
+
rows: rowCounts.length,
|
|
14300
|
+
rowCounts,
|
|
14189
14301
|
tileWidth,
|
|
14190
14302
|
tileHeight,
|
|
14191
14303
|
titleHeight,
|
|
@@ -14249,36 +14361,20 @@ function titleSvg(width, height, caption) {
|
|
|
14249
14361
|
*
|
|
14250
14362
|
* A tile sharp cannot read is DROPPED and counted, never composited as a blank
|
|
14251
14363
|
* cell — a black square in a digest reads as "the camera saw nothing", which is
|
|
14252
|
-
* the opposite of what happened. The
|
|
14253
|
-
*
|
|
14364
|
+
* the opposite of what happened. The layout is computed from the SURVIVORS, and
|
|
14365
|
+
* because {@link mosaicRowCounts} counts rows rather than grid cells there is no
|
|
14366
|
+
* remainder to leave empty either.
|
|
14254
14367
|
*
|
|
14255
|
-
* @throws
|
|
14256
|
-
* treats
|
|
14368
|
+
* @throws when every tile was dropped, and when sharp cannot produce the final
|
|
14369
|
+
* canvas at all; the caller treats both as "no mosaic" and still
|
|
14370
|
+
* delivers the counts.
|
|
14257
14371
|
*/
|
|
14258
14372
|
async function renderMosaic(request, logger) {
|
|
14259
14373
|
const plan = mosaicTextPlan(request.captions);
|
|
14260
|
-
const
|
|
14261
|
-
const probeLayout = mosaicLayout({
|
|
14262
|
-
tileCount: Math.max(1, request.tiles.length),
|
|
14263
|
-
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {}
|
|
14264
|
-
});
|
|
14374
|
+
const usable = [];
|
|
14265
14375
|
for (const tile of request.tiles) try {
|
|
14266
|
-
|
|
14267
|
-
|
|
14268
|
-
width: probeLayout.tileWidth,
|
|
14269
|
-
height: probeLayout.tileHeight,
|
|
14270
|
-
fit: "contain",
|
|
14271
|
-
background: MOSAIC_BACKGROUND
|
|
14272
|
-
});
|
|
14273
|
-
const resized = await (label.length === 0 ? fitted : fitted.composite([{
|
|
14274
|
-
input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, label),
|
|
14275
|
-
top: 0,
|
|
14276
|
-
left: 0
|
|
14277
|
-
}])).jpeg({ quality: 82 }).toBuffer();
|
|
14278
|
-
decoded.push({
|
|
14279
|
-
tile,
|
|
14280
|
-
resized
|
|
14281
|
-
});
|
|
14376
|
+
await (0, sharp.default)(tile.jpeg).metadata();
|
|
14377
|
+
usable.push(tile);
|
|
14282
14378
|
} catch (err) {
|
|
14283
14379
|
logger.warn("summary mosaic: a tile could not be decoded and was dropped", {
|
|
14284
14380
|
tags: { deviceId: tile.deviceId },
|
|
@@ -14288,23 +14384,34 @@ async function renderMosaic(request, logger) {
|
|
|
14288
14384
|
}
|
|
14289
14385
|
});
|
|
14290
14386
|
}
|
|
14291
|
-
const droppedTiles = request.tiles.length -
|
|
14387
|
+
const droppedTiles = request.tiles.length - usable.length;
|
|
14388
|
+
if (usable.length === 0) throw new Error(`summary mosaic: no tile could be decoded (${request.tiles.length} offered)`);
|
|
14292
14389
|
const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
|
|
14293
14390
|
const layout = mosaicLayout({
|
|
14294
|
-
tileCount:
|
|
14391
|
+
tileCount: usable.length,
|
|
14295
14392
|
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
|
|
14296
14393
|
withTitle: caption.length > 0
|
|
14297
14394
|
});
|
|
14298
|
-
const composites = await Promise.all(
|
|
14299
|
-
|
|
14300
|
-
|
|
14301
|
-
|
|
14395
|
+
const composites = await Promise.all(usable.map(async (tile, index) => {
|
|
14396
|
+
const rect = layout.tiles[index];
|
|
14397
|
+
if (rect === void 0) throw new Error("summary mosaic: layout lost a tile");
|
|
14398
|
+
const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
|
|
14399
|
+
const fitted = (0, sharp.default)(tile.jpeg).resize({
|
|
14400
|
+
width: rect.w,
|
|
14401
|
+
height: rect.h,
|
|
14302
14402
|
fit: "contain",
|
|
14303
14403
|
background: MOSAIC_BACKGROUND
|
|
14304
|
-
})
|
|
14305
|
-
|
|
14306
|
-
|
|
14307
|
-
|
|
14404
|
+
});
|
|
14405
|
+
return {
|
|
14406
|
+
input: await (label.length === 0 ? fitted : fitted.composite([{
|
|
14407
|
+
input: tileLabelSvg(rect.w, rect.h, label),
|
|
14408
|
+
top: 0,
|
|
14409
|
+
left: 0
|
|
14410
|
+
}])).jpeg({ quality: 82 }).toBuffer(),
|
|
14411
|
+
top: rect.y,
|
|
14412
|
+
left: rect.x
|
|
14413
|
+
};
|
|
14414
|
+
}));
|
|
14308
14415
|
if (caption.length > 0 && layout.titleHeight > 0) composites.unshift({
|
|
14309
14416
|
input: titleSvg(layout.width, layout.titleHeight, caption),
|
|
14310
14417
|
top: 0,
|
|
@@ -14520,6 +14627,36 @@ function buildSummaryOutboxInputs(input) {
|
|
|
14520
14627
|
* the quota degrades honestly: the best cameras get their one tile, the rest
|
|
14521
14628
|
* are counted as dropped and the count rides the log line — never silently.
|
|
14522
14629
|
*
|
|
14630
|
+
* ## Only tracks the pipeline PROMOTED
|
|
14631
|
+
*
|
|
14632
|
+
* A digest is retrospective. The operator's own framing, verbatim: *"ideale
|
|
14633
|
+
* sarebbe considerare solo tracce che vengono alla fine promosse a tracce, dato
|
|
14634
|
+
* che il summary non è atteso di essere realtime"* — the window is closed
|
|
14635
|
+
* before this function is called, so the selection may wait for the pipeline's
|
|
14636
|
+
* verdict instead of guessing at it.
|
|
14637
|
+
*
|
|
14638
|
+
* The verdict is `SortTracker`'s own promotion threshold: a candidate is not
|
|
14639
|
+
* emitted as a track until it has been matched on `minHits` frames
|
|
14640
|
+
* (`isConfirmedForEmit`, per-device setting, *"minimum consecutive detection
|
|
14641
|
+
* hits before a track is promoted to output"*). The gate here re-applies that
|
|
14642
|
+
* same threshold to what the CLOSED row actually left behind — the count of
|
|
14643
|
+
* recorded sightings on its trajectory — and the port that builds a candidate
|
|
14644
|
+
* is the only thing that knows the device's `minHits`.
|
|
14645
|
+
*
|
|
14646
|
+
* This is not a movement heuristic and deliberately so: `totalDistance` and
|
|
14647
|
+
* `state` were both measured against live traffic and both hide real passages.
|
|
14648
|
+
* What they cannot see is the shape this gate is for — a row whose whole life
|
|
14649
|
+
* is one or two frames. Measured on device 617 (2026-08-19 04:12): two rows,
|
|
14650
|
+
* `closedReason` NULL so the parked pass is blind to them, dwell 53 ms,
|
|
14651
|
+
* `totalDistance` 0, two recorded sightings each. And `minImportance` cannot
|
|
14652
|
+
* cover it either, structurally: the static-track gate that demotes a frozen
|
|
14653
|
+
* subject needs two centroids to measure anything
|
|
14654
|
+
* (`computeStaticTrackMetrics`), so a single-sighting row is exactly the shape
|
|
14655
|
+
* it never scores down — 33 of the live hub's 34 such rows sit at or above 0.2.
|
|
14656
|
+
*
|
|
14657
|
+
* There is no rule field to turn it off. A digest of things that were never
|
|
14658
|
+
* tracks has no audience, and this repo has shipped knobs nobody read.
|
|
14659
|
+
*
|
|
14523
14660
|
* ## Zones are scoped PER CAMERA
|
|
14524
14661
|
*
|
|
14525
14662
|
* `filters.zones` is a flat id list on a rule that spans cameras, and zone ids
|
|
@@ -14543,15 +14680,20 @@ function dwellSeconds(candidate) {
|
|
|
14543
14680
|
* branch that discards work silently reads as "it never happened".
|
|
14544
14681
|
*/
|
|
14545
14682
|
function filterSummaryCandidates(candidates, filters, zoneOwner) {
|
|
14546
|
-
const
|
|
14547
|
-
const
|
|
14683
|
+
const promoted = candidates.filter((candidate) => candidate.confirmed !== false);
|
|
14684
|
+
const droppedUnconfirmed = candidates.length - promoted.length;
|
|
14685
|
+
const unknownConfirmation = promoted.filter((candidate) => candidate.confirmed === void 0).length;
|
|
14686
|
+
const moving = filters?.includeStationary === true ? promoted : promoted.filter((candidate) => candidate.stationary !== true);
|
|
14687
|
+
const droppedStationary = promoted.length - moving.length;
|
|
14548
14688
|
if (filters === void 0) return {
|
|
14549
14689
|
kept: [...moving],
|
|
14550
14690
|
droppedByClass: 0,
|
|
14551
14691
|
droppedByZone: 0,
|
|
14552
14692
|
droppedByImportance: 0,
|
|
14553
14693
|
droppedByDwell: 0,
|
|
14554
|
-
droppedStationary
|
|
14694
|
+
droppedStationary,
|
|
14695
|
+
droppedUnconfirmed,
|
|
14696
|
+
unknownConfirmation
|
|
14555
14697
|
};
|
|
14556
14698
|
const include = filters.classes;
|
|
14557
14699
|
const exclude = new Set(filters.classesExclude ?? []);
|
|
@@ -14597,7 +14739,9 @@ function filterSummaryCandidates(candidates, filters, zoneOwner) {
|
|
|
14597
14739
|
droppedByZone,
|
|
14598
14740
|
droppedByImportance,
|
|
14599
14741
|
droppedByDwell,
|
|
14600
|
-
droppedStationary
|
|
14742
|
+
droppedStationary,
|
|
14743
|
+
droppedUnconfirmed,
|
|
14744
|
+
unknownConfirmation
|
|
14601
14745
|
};
|
|
14602
14746
|
}
|
|
14603
14747
|
/**
|
|
@@ -14973,7 +15117,9 @@ var NcSummaryProducer = class {
|
|
|
14973
15117
|
droppedByZone: filtered.droppedByZone,
|
|
14974
15118
|
droppedByImportance: filtered.droppedByImportance,
|
|
14975
15119
|
droppedByDwell: filtered.droppedByDwell,
|
|
14976
|
-
droppedStationary: filtered.droppedStationary
|
|
15120
|
+
droppedStationary: filtered.droppedStationary,
|
|
15121
|
+
droppedUnconfirmed: filtered.droppedUnconfirmed,
|
|
15122
|
+
unknownConfirmation: filtered.unknownConfirmation
|
|
14977
15123
|
} });
|
|
14978
15124
|
if (opts.stamp) await this.deps.store.markGenerated(rule.id, window.endMs);
|
|
14979
15125
|
return {
|
|
@@ -15037,6 +15183,8 @@ var NcSummaryProducer = class {
|
|
|
15037
15183
|
scanned: candidates.length,
|
|
15038
15184
|
matched: filtered.kept.length,
|
|
15039
15185
|
droppedStationary: filtered.droppedStationary,
|
|
15186
|
+
droppedUnconfirmed: filtered.droppedUnconfirmed,
|
|
15187
|
+
unknownConfirmation: filtered.unknownConfirmation,
|
|
15040
15188
|
droppedByClass: filtered.droppedByClass,
|
|
15041
15189
|
tiles: loaded.length,
|
|
15042
15190
|
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
@@ -16094,7 +16242,7 @@ function isSyntheticId(id) {
|
|
|
16094
16242
|
* - and, since 2026-08-15, a `trackId` for both AUDIO kinds: a sound owns no
|
|
16095
16243
|
* frame, so production photographs the camera at the match and holds the
|
|
16096
16244
|
* bytes on the still shelf under an owner id the subject names
|
|
16097
|
-
* (`
|
|
16245
|
+
* (`still-shelf.ts`). The tester's RAM-held frame stands in for exactly that
|
|
16098
16246
|
* photograph, and — unlike every other kind here — it is taken the same way
|
|
16099
16247
|
* production takes it, at the same moment, from the same cap.
|
|
16100
16248
|
*
|
|
@@ -18761,11 +18909,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
18761
18909
|
*/
|
|
18762
18910
|
syntheticMedia = /* @__PURE__ */ new Map();
|
|
18763
18911
|
/**
|
|
18764
|
-
* The photograph a
|
|
18765
|
-
*
|
|
18766
|
-
*
|
|
18912
|
+
* The photograph a notification carries when the subject it is about is an
|
|
18913
|
+
* ABSENCE — a sound that owns no frame, an emptied occupancy scope that owns
|
|
18914
|
+
* no member. See `still-shelf.ts`. Null when this wiring has no snapshot cap,
|
|
18915
|
+
* which leaves those notifications text-only rather than broken.
|
|
18767
18916
|
*/
|
|
18768
|
-
|
|
18917
|
+
stills;
|
|
18769
18918
|
/** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
|
|
18770
18919
|
lastNoMatchReportAt = /* @__PURE__ */ new Map();
|
|
18771
18920
|
noMatchSuppressed = /* @__PURE__ */ new Map();
|
|
@@ -19062,9 +19211,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19062
19211
|
this.logger = deps.logger;
|
|
19063
19212
|
this.now = deps.now ?? (() => Date.now());
|
|
19064
19213
|
const getSnapshot = deps.getSnapshot;
|
|
19065
|
-
this.
|
|
19214
|
+
this.stills = getSnapshot !== void 0 ? new NcStillShelf({
|
|
19066
19215
|
getSnapshot,
|
|
19067
|
-
logger: this.logger.child("
|
|
19216
|
+
logger: this.logger.child("still-shelf"),
|
|
19068
19217
|
now: this.now
|
|
19069
19218
|
}) : null;
|
|
19070
19219
|
this.rules = new NcRuleStore({
|
|
@@ -19154,8 +19303,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19154
19303
|
getMediaForOwner: async (ownerKind, ownerId) => {
|
|
19155
19304
|
const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
|
|
19156
19305
|
if (held !== void 0) return held.files;
|
|
19157
|
-
const
|
|
19158
|
-
if (
|
|
19306
|
+
const shelved = isStillShelfId(ownerId) ? this.stills?.get(ownerId) : void 0;
|
|
19307
|
+
if (shelved !== void 0) return shelved;
|
|
19159
19308
|
return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
|
|
19160
19309
|
},
|
|
19161
19310
|
...deps.now !== void 0 ? { now: deps.now } : {},
|
|
@@ -19361,7 +19510,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19361
19510
|
}
|
|
19362
19511
|
this.timelapseScheduler?.stop();
|
|
19363
19512
|
this.summaryProducer?.stop();
|
|
19364
|
-
this.
|
|
19513
|
+
this.stills?.clear();
|
|
19365
19514
|
this.addonUpdates.dispose();
|
|
19366
19515
|
this.livenessHoldoff.dispose();
|
|
19367
19516
|
this.evaluationActive = false;
|
|
@@ -19713,14 +19862,14 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19713
19862
|
* direction that DESTROYS the media.
|
|
19714
19863
|
*/
|
|
19715
19864
|
audioStillOwner(deviceId, atMs, mode) {
|
|
19716
|
-
const shelf = this.
|
|
19865
|
+
const shelf = this.stills;
|
|
19717
19866
|
if (shelf === null || !this.evaluationActive) return void 0;
|
|
19718
19867
|
for (const rule of this.rules.listEnabled("immediate")) {
|
|
19719
19868
|
if (rule.media.attach === "none") continue;
|
|
19720
19869
|
if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
|
|
19721
19870
|
const devices = rule.conditions.devices;
|
|
19722
19871
|
if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
|
|
19723
|
-
return shelf.capture(deviceId, atMs);
|
|
19872
|
+
return shelf.capture(deviceId, atMs, "audio");
|
|
19724
19873
|
}
|
|
19725
19874
|
}
|
|
19726
19875
|
/**
|
|
@@ -19779,7 +19928,48 @@ var NotificationCenter = class NotificationCenter {
|
|
|
19779
19928
|
* Public so a test / a future out-of-band edge source can drive it directly.
|
|
19780
19929
|
*/
|
|
19781
19930
|
onOccupancyEdge(edge) {
|
|
19782
|
-
this.consumeEvent(incomingFromOccupancyEdge(edge));
|
|
19931
|
+
this.consumeEvent(incomingFromOccupancyEdge(edge, this.occupancyStillOwner(edge)));
|
|
19932
|
+
}
|
|
19933
|
+
/**
|
|
19934
|
+
* Start photographing this edge's camera NOW and return the owner id its
|
|
19935
|
+
* subject should name — or `undefined` when nothing would use it.
|
|
19936
|
+
*
|
|
19937
|
+
* **Only an edge that owns NOBODY.** `chooseOccupancyMediaOwner` names a
|
|
19938
|
+
* member track whenever the scope holds one, and that picture is of the
|
|
19939
|
+
* counted object itself; a photograph is strictly the stand-in for not having
|
|
19940
|
+
* one. The case this exists for is "posto libero" (camera 617): the vehicle
|
|
19941
|
+
* whose departure IS the notification has left, so the scope is empty, the
|
|
19942
|
+
* ladder resolves nothing and the delivery logged `no still could be resolved
|
|
19943
|
+
* … owners=[]`. A became-free edge has no owner BY CONSTRUCTION, and a
|
|
19944
|
+
* became-occupied edge can lack one too (every member parked, i.e.
|
|
19945
|
+
* `stationary:`) — so the gate is the ABSENCE itself, not the polarity. That
|
|
19946
|
+
* is the honest predicate: photograph exactly the edges whose media cannot be
|
|
19947
|
+
* resolved, and no others.
|
|
19948
|
+
*
|
|
19949
|
+
* **The capture is not awaited**, for the audio shelf's reason and one of its
|
|
19950
|
+
* own: a freed space is about to be taken by the next car, and a still fetched
|
|
19951
|
+
* after the outbox drained would show it occupied again.
|
|
19952
|
+
*
|
|
19953
|
+
* **The gate is the RULE's media policy AND the engine's own verdict.** The
|
|
19954
|
+
* watcher commits both polarities of every watched key, so a hub with only a
|
|
19955
|
+
* became-occupied rule still produces free edges — photographing those would
|
|
19956
|
+
* be a snapshot no notification can ever carry. `matchesOccupancy` is the same
|
|
19957
|
+
* predicate `evaluateRule` will apply, shared rather than restated. The rule
|
|
19958
|
+
* cache is an in-memory mirror, so this is not a fallible read (D49).
|
|
19959
|
+
*/
|
|
19960
|
+
occupancyStillOwner(edge) {
|
|
19961
|
+
const shelf = this.stills;
|
|
19962
|
+
if (shelf === null || !this.evaluationActive) return void 0;
|
|
19963
|
+
if (edge.mediaTrackId !== void 0) return void 0;
|
|
19964
|
+
const subject = occupancySubjectFromEdge(edge);
|
|
19965
|
+
for (const rule of this.rules.listEnabled("device-event")) {
|
|
19966
|
+
if (rule.media.attach === "none") continue;
|
|
19967
|
+
const occ = rule.conditions.occupancy;
|
|
19968
|
+
if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
|
|
19969
|
+
const devices = rule.conditions.devices;
|
|
19970
|
+
if (devices !== void 0 && devices.length > 0 && !devices.includes(edge.deviceId)) continue;
|
|
19971
|
+
return shelf.capture(edge.deviceId, edge.timestamp, "occupancy");
|
|
19972
|
+
}
|
|
19783
19973
|
}
|
|
19784
19974
|
/**
|
|
19785
19975
|
* Feed ONE audio inference frame into the sampling-window matcher.
|
|
@@ -20746,7 +20936,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20746
20936
|
kind,
|
|
20747
20937
|
ruleId: rule.id,
|
|
20748
20938
|
rule: rule.name,
|
|
20749
|
-
snapshotWired: this.
|
|
20939
|
+
snapshotWired: this.stills !== null
|
|
20750
20940
|
}
|
|
20751
20941
|
});
|
|
20752
20942
|
}
|
|
@@ -20911,6 +21101,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
20911
21101
|
if (subject.kind === "audio-window" || subject.kind === "audio-event") return { outcome: "disabled" };
|
|
20912
21102
|
const trackId = subject.trackId;
|
|
20913
21103
|
if (trackId === void 0) return { outcome: "disabled" };
|
|
21104
|
+
if (isStillShelfId(trackId)) return { outcome: "disabled" };
|
|
20914
21105
|
const member = {
|
|
20915
21106
|
trackId,
|
|
20916
21107
|
className: subject.classNames[0] ?? "event",
|
|
@@ -22153,6 +22344,16 @@ function planRuleTest(input) {
|
|
|
22153
22344
|
* never be re-owned through a patch.
|
|
22154
22345
|
* - `nc.setRuleTargetEnabled` — a user opting HIS OWN target out of a rule
|
|
22155
22346
|
* VISIBLE to him (his own personal rule OR a global/admin rule).
|
|
22347
|
+
* - the TIMELAPSE and DIGEST families (`nc.*TimelapseRule*`,
|
|
22348
|
+
* `nc.*SummaryRule*`) repeat that model exactly over their own stores. The
|
|
22349
|
+
* digest half was `auth: 'admin'` in v1 and was relaxed once the operator
|
|
22350
|
+
* closed the cost question; `nc.listSummaryRules` reports the current answer
|
|
22351
|
+
* as `canAuthor` so a client talking to an older hub degrades to a read-only
|
|
22352
|
+
* list instead of discovering the refusal as a 403 on a filled-in form.
|
|
22353
|
+
* - the three TEST actions (`nc.injectTestEvent`, `nc.testRule`,
|
|
22354
|
+
* `nc.testTimelapseRule`, `nc.testSummaryRule`) stay `auth: 'admin'`
|
|
22355
|
+
* regardless of who owns the rule: they DELIVER, to every target the rule
|
|
22356
|
+
* addresses, on other people's phones.
|
|
22156
22357
|
*
|
|
22157
22358
|
* Target-ownership limits (personal-rule delivery target list + the opt-out
|
|
22158
22359
|
* target) are enforced against the notifiers target catalog; ADMINS bypass the
|
|
@@ -22371,7 +22572,25 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
22371
22572
|
auth: "admin",
|
|
22372
22573
|
caller: "required"
|
|
22373
22574
|
}),
|
|
22374
|
-
"nc.listSummaryRules": require_dist.customAction(require_dist.object({}), require_dist.object({
|
|
22575
|
+
"nc.listSummaryRules": require_dist.customAction(require_dist.object({}), require_dist.object({
|
|
22576
|
+
rules: require_dist.array(NcViewerSummaryRuleSchema),
|
|
22577
|
+
/**
|
|
22578
|
+
* DOES THIS HUB LET THE CALLER AUTHOR ONE? The capability probe, and the
|
|
22579
|
+
* only honest way a client can know.
|
|
22580
|
+
*
|
|
22581
|
+
* `nc.upsertSummaryRule` was `auth: 'admin'` until the operator closed
|
|
22582
|
+
* that decision, and a viewer talking to a hub that still refuses has to
|
|
22583
|
+
* find out BEFORE it offers a "New summary rule" button — otherwise the
|
|
22584
|
+
* only feedback is a 403 taken in front of the operator on a form they
|
|
22585
|
+
* had already filled in. ADDITIVE and therefore `.optional()` on every
|
|
22586
|
+
* client mirror: absent means an older hub, which the viewer reads as "no
|
|
22587
|
+
* authoring" and degrades to a read-only list with a note.
|
|
22588
|
+
*
|
|
22589
|
+
* Derived from the same predicate the handlers enforce — never a constant
|
|
22590
|
+
* `true` — so a hub that re-tightened the auth could not say otherwise.
|
|
22591
|
+
*/
|
|
22592
|
+
canAuthor: require_dist.boolean()
|
|
22593
|
+
}), { caller: "required" }),
|
|
22375
22594
|
/**
|
|
22376
22595
|
* ONE action for create AND update, like `nc.upsertTimelapseRule`: a digest
|
|
22377
22596
|
* rule is authored as a whole (cameras + window + filters + targets) and the
|
|
@@ -22380,10 +22599,18 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
22380
22599
|
*
|
|
22381
22600
|
* `ruleId` absent ⇒ create from `rule`; present ⇒ patch.
|
|
22382
22601
|
*
|
|
22383
|
-
* `
|
|
22384
|
-
*
|
|
22385
|
-
*
|
|
22386
|
-
*
|
|
22602
|
+
* `caller: 'required'`, NOT `auth: 'admin'`. It was admin-only in v1 because
|
|
22603
|
+
* a digest spans cameras and consumes the local vision model, and that cost
|
|
22604
|
+
* had not been watched yet. The operator closed the decision, so this now
|
|
22605
|
+
* carries exactly the auth the two rule families beside it carry: ownership
|
|
22606
|
+
* is enforced in the handler, `ownerUserId` is STAMPED from the resolved
|
|
22607
|
+
* caller, a personal rule may deliver only to targets that caller OWNS, and an
|
|
22608
|
+
* admin still authors GLOBAL rules. No new ownership model was invented for
|
|
22609
|
+
* it — `NcSummaryStore` has had `ownerUserId` / `listForOwner` since it was
|
|
22610
|
+
* written and they were simply not reachable.
|
|
22611
|
+
*
|
|
22612
|
+
* `nc.testSummaryRule` stays admin: that one DELIVERS, on other people's
|
|
22613
|
+
* phones.
|
|
22387
22614
|
*/
|
|
22388
22615
|
"nc.upsertSummaryRule": require_dist.customAction(require_dist.object({
|
|
22389
22616
|
ruleId: require_dist.string().optional(),
|
|
@@ -22391,12 +22618,10 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
22391
22618
|
patch: NcSummaryRulePatchSchema.optional()
|
|
22392
22619
|
}), require_dist.object({ rule: NcSummaryRuleSchema }), {
|
|
22393
22620
|
kind: "mutation",
|
|
22394
|
-
auth: "admin",
|
|
22395
22621
|
caller: "required"
|
|
22396
22622
|
}),
|
|
22397
22623
|
"nc.deleteSummaryRule": require_dist.customAction(require_dist.object({ ruleId: require_dist.string() }), require_dist.object({ success: require_dist.literal(true) }), {
|
|
22398
22624
|
kind: "mutation",
|
|
22399
|
-
auth: "admin",
|
|
22400
22625
|
caller: "required"
|
|
22401
22626
|
}),
|
|
22402
22627
|
/**
|
|
@@ -22490,6 +22715,17 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
22490
22715
|
* a surface that distinguishes them reports what other people own. */
|
|
22491
22716
|
"nc.getArtifact": require_dist.customAction(require_dist.object({ artifactId: require_dist.string().min(1) }), require_dist.object({ artifact: NcArtifactRecordSchema.nullable() }), { caller: "required" })
|
|
22492
22717
|
});
|
|
22718
|
+
/**
|
|
22719
|
+
* Does THIS build let a non-admin author a digest rule?
|
|
22720
|
+
*
|
|
22721
|
+
* A named constant rather than a literal `true` at the call site, so the one
|
|
22722
|
+
* fact `nc.listSummaryRules.canAuthor` reports and the auth the upsert actually
|
|
22723
|
+
* carries sit beside each other and are changed together. If the operator ever
|
|
22724
|
+
* re-tightens the action, this goes to `false` in the same edit and the viewer
|
|
22725
|
+
* degrades on the next list — instead of the app discovering it by taking a 403
|
|
22726
|
+
* on a form the operator had already filled in.
|
|
22727
|
+
*/
|
|
22728
|
+
var SUMMARY_AUTHORING_OPEN = true;
|
|
22493
22729
|
/** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
|
|
22494
22730
|
function requireCaller$1(caller) {
|
|
22495
22731
|
if (!caller || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error("forbidden: authenticated caller required");
|
|
@@ -22550,6 +22786,22 @@ function makeNcActionHandlers(deps) {
|
|
|
22550
22786
|
if (editor === void 0) throw new Error("this node does not host the notification text catalog — use the hub");
|
|
22551
22787
|
return editor;
|
|
22552
22788
|
};
|
|
22789
|
+
/**
|
|
22790
|
+
* A digest rule the caller may EDIT. Same shape and same admin bypass as
|
|
22791
|
+
* {@link assertTimelapseEditable} — deliberately, because a second ownership
|
|
22792
|
+
* model for the third rule family is a second place to get it wrong.
|
|
22793
|
+
*
|
|
22794
|
+
* A GLOBAL rule (no `ownerUserId`, what an admin authors) is VISIBLE to
|
|
22795
|
+
* everyone and editable by admins only: `listForOwner` returns it, so a user
|
|
22796
|
+
* sees it in the list, and `readOnly` says so.
|
|
22797
|
+
*/
|
|
22798
|
+
const assertSummaryEditable = (ruleId, caller, sum) => {
|
|
22799
|
+
const rule = sum.store.get(ruleId);
|
|
22800
|
+
if (rule === null) throw new Error(`forbidden: summary rule not found: ${ruleId}`);
|
|
22801
|
+
if (caller.isAdmin) return rule;
|
|
22802
|
+
if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: summary rule not owned: ${ruleId}`);
|
|
22803
|
+
return rule;
|
|
22804
|
+
};
|
|
22553
22805
|
/** A timelapse rule the caller may EDIT. Same admin bypass as `NcRule`. */
|
|
22554
22806
|
const assertTimelapseEditable = (ruleId, caller, tl) => {
|
|
22555
22807
|
const rule = tl.store.get(ruleId);
|
|
@@ -22820,21 +23072,23 @@ function makeNcActionHandlers(deps) {
|
|
|
22820
23072
|
"nc.listSummaryRules": async (_input, caller) => {
|
|
22821
23073
|
const c = requireCaller$1(caller);
|
|
22822
23074
|
const sum = requireSummary();
|
|
22823
|
-
return {
|
|
22824
|
-
|
|
22825
|
-
|
|
22826
|
-
|
|
23075
|
+
return {
|
|
23076
|
+
rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
|
|
23077
|
+
...r,
|
|
23078
|
+
readOnly: !c.isAdmin && r.ownerUserId !== c.userId
|
|
23079
|
+
})),
|
|
23080
|
+
canAuthor: SUMMARY_AUTHORING_OPEN
|
|
23081
|
+
};
|
|
22827
23082
|
},
|
|
22828
23083
|
"nc.upsertSummaryRule": async (input, caller) => {
|
|
22829
23084
|
const c = requireCaller$1(caller);
|
|
22830
|
-
if (!c.isAdmin) throw new Error("forbidden: admin required to author a summary rule");
|
|
22831
23085
|
const sum = requireSummary();
|
|
22832
23086
|
if (input.ruleId === void 0) {
|
|
22833
23087
|
if (input.rule === void 0) throw new Error("upsertSummaryRule: rule or ruleId required");
|
|
22834
23088
|
const parsed = NcSummaryRuleInputSchema.parse(input.rule);
|
|
22835
23089
|
assertSummaryBudget(parsed);
|
|
22836
23090
|
await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
|
|
22837
|
-
const rule = await sum.store.create(parsed, c.userId);
|
|
23091
|
+
const rule = await sum.store.create(parsed, c.userId, c.isAdmin ? void 0 : c.userId);
|
|
22838
23092
|
deps.logger.info("summary rule created", { meta: {
|
|
22839
23093
|
ruleId: rule.id,
|
|
22840
23094
|
owner: c.userId,
|
|
@@ -22842,8 +23096,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22842
23096
|
} });
|
|
22843
23097
|
return { rule };
|
|
22844
23098
|
}
|
|
22845
|
-
const existing =
|
|
22846
|
-
if (existing === null) throw new Error(`forbidden: summary rule not found: ${input.ruleId}`);
|
|
23099
|
+
const existing = assertSummaryEditable(input.ruleId, c, sum);
|
|
22847
23100
|
if (input.patch === void 0) throw new Error("upsertSummaryRule: patch required");
|
|
22848
23101
|
const patch = NcSummaryRulePatchSchema.parse(input.patch);
|
|
22849
23102
|
assertSummaryBudget({
|
|
@@ -22860,8 +23113,9 @@ function makeNcActionHandlers(deps) {
|
|
|
22860
23113
|
},
|
|
22861
23114
|
"nc.deleteSummaryRule": async (input, caller) => {
|
|
22862
23115
|
const c = requireCaller$1(caller);
|
|
22863
|
-
|
|
22864
|
-
|
|
23116
|
+
const sum = requireSummary();
|
|
23117
|
+
assertSummaryEditable(input.ruleId, c, sum);
|
|
23118
|
+
await sum.store.delete(input.ruleId);
|
|
22865
23119
|
deps.logger.info("summary rule deleted", { meta: {
|
|
22866
23120
|
ruleId: input.ruleId,
|
|
22867
23121
|
owner: c.userId
|
|
@@ -44178,7 +44432,9 @@ var TrackStore = class {
|
|
|
44178
44432
|
*/
|
|
44179
44433
|
rowToTrack(id, data, projection) {
|
|
44180
44434
|
const slim = projection === "slim";
|
|
44181
|
-
const
|
|
44435
|
+
const storedPositions = data["positions"] ?? [];
|
|
44436
|
+
const observations = storedPositions.length;
|
|
44437
|
+
const positions = slim ? [] : storedPositions;
|
|
44182
44438
|
const snapshots = slim ? [] : data["snapshots"] ?? [];
|
|
44183
44439
|
const zones = data["zonesVisited"] ?? [];
|
|
44184
44440
|
const classes = data["classes"];
|
|
@@ -44235,6 +44491,7 @@ var TrackStore = class {
|
|
|
44235
44491
|
...typeof hasFace === "boolean" ? { hasFace } : {},
|
|
44236
44492
|
...typeof hasRider === "boolean" ? { hasRider } : {},
|
|
44237
44493
|
...closedReason !== void 0 ? { closedReason } : {},
|
|
44494
|
+
observations,
|
|
44238
44495
|
...envelope !== null ? { envelope } : {}
|
|
44239
44496
|
};
|
|
44240
44497
|
}
|
|
@@ -52544,13 +52801,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
52544
52801
|
buildSummaryPorts(stores) {
|
|
52545
52802
|
return {
|
|
52546
52803
|
listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
|
|
52547
|
-
|
|
52804
|
+
const tracks = await stores.trackStore.queryHistorical({
|
|
52548
52805
|
deviceId,
|
|
52549
52806
|
since: sinceMs,
|
|
52550
52807
|
until: untilMs,
|
|
52551
52808
|
projection: "slim",
|
|
52552
52809
|
limit
|
|
52553
|
-
})
|
|
52810
|
+
});
|
|
52811
|
+
const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
|
|
52812
|
+
return tracks.map((t) => ({
|
|
52554
52813
|
trackId: t.trackId,
|
|
52555
52814
|
deviceId: t.deviceId,
|
|
52556
52815
|
className: t.className,
|
|
@@ -52559,7 +52818,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
52559
52818
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
52560
52819
|
zones: [...t.zonesVisited],
|
|
52561
52820
|
...t.classes !== void 0 ? { classes: [...t.classes] } : {},
|
|
52562
|
-
...t.closedReason === "stationary-promotion" ? { stationary: true } : {}
|
|
52821
|
+
...t.closedReason === "stationary-promotion" ? { stationary: true } : {},
|
|
52822
|
+
...t.observations !== void 0 ? { confirmed: t.observations >= minHits } : {}
|
|
52563
52823
|
}));
|
|
52564
52824
|
},
|
|
52565
52825
|
listMediaInfo: async (trackId) => {
|