@camstack/addon-post-analysis 1.2.96 → 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/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BdX09HCw.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DyXoKV7Z.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-DULHUyr1.mjs → hostInit-CkEakaQn.mjs} +1 -1
- package/dist/pipeline-analytics/index.js +238 -76
- package/dist/pipeline-analytics/index.mjs +238 -76
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -14206,7 +14206,11 @@ function mosaicTextPlan(captions) {
|
|
|
14206
14206
|
};
|
|
14207
14207
|
}
|
|
14208
14208
|
/** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
|
|
14209
|
-
* 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. */
|
|
14210
14214
|
function mosaicGrid(n) {
|
|
14211
14215
|
const count = Math.max(1, Math.floor(n));
|
|
14212
14216
|
const cols = Math.ceil(Math.sqrt(count));
|
|
@@ -14216,31 +14220,84 @@ function mosaicGrid(n) {
|
|
|
14216
14220
|
};
|
|
14217
14221
|
}
|
|
14218
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
|
+
/**
|
|
14219
14258
|
* Where every tile lands. Pure — no bytes, no sharp, no clock.
|
|
14220
14259
|
*
|
|
14221
14260
|
* Exported because the rectangles are DATA the artefact should carry: a viewer
|
|
14222
14261
|
* that wants a tappable mosaic reads these rather than re-deriving a layout
|
|
14223
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.
|
|
14224
14266
|
*/
|
|
14225
14267
|
function mosaicLayout(input) {
|
|
14226
|
-
const
|
|
14227
|
-
const
|
|
14228
|
-
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);
|
|
14229
14272
|
const tileHeight = Math.round(tileWidth * TILE_ASPECT);
|
|
14230
14273
|
const titleHeight = input.withTitle === true ? Math.round(tileHeight * TITLE_BAND_FRACTION) : 0;
|
|
14231
14274
|
const tiles = [];
|
|
14232
|
-
|
|
14233
|
-
|
|
14234
|
-
|
|
14235
|
-
|
|
14236
|
-
|
|
14237
|
-
|
|
14238
|
-
|
|
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
|
+
}
|
|
14239
14295
|
return {
|
|
14240
|
-
width
|
|
14241
|
-
height:
|
|
14242
|
-
cols,
|
|
14243
|
-
rows,
|
|
14296
|
+
width,
|
|
14297
|
+
height: y,
|
|
14298
|
+
cols: Math.max(...rowCounts),
|
|
14299
|
+
rows: rowCounts.length,
|
|
14300
|
+
rowCounts,
|
|
14244
14301
|
tileWidth,
|
|
14245
14302
|
tileHeight,
|
|
14246
14303
|
titleHeight,
|
|
@@ -14304,36 +14361,20 @@ function titleSvg(width, height, caption) {
|
|
|
14304
14361
|
*
|
|
14305
14362
|
* A tile sharp cannot read is DROPPED and counted, never composited as a blank
|
|
14306
14363
|
* cell — a black square in a digest reads as "the camera saw nothing", which is
|
|
14307
|
-
* the opposite of what happened. The
|
|
14308
|
-
*
|
|
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.
|
|
14309
14367
|
*
|
|
14310
|
-
* @throws
|
|
14311
|
-
* 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.
|
|
14312
14371
|
*/
|
|
14313
14372
|
async function renderMosaic(request, logger) {
|
|
14314
14373
|
const plan = mosaicTextPlan(request.captions);
|
|
14315
|
-
const
|
|
14316
|
-
const probeLayout = mosaicLayout({
|
|
14317
|
-
tileCount: Math.max(1, request.tiles.length),
|
|
14318
|
-
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {}
|
|
14319
|
-
});
|
|
14374
|
+
const usable = [];
|
|
14320
14375
|
for (const tile of request.tiles) try {
|
|
14321
|
-
|
|
14322
|
-
|
|
14323
|
-
width: probeLayout.tileWidth,
|
|
14324
|
-
height: probeLayout.tileHeight,
|
|
14325
|
-
fit: "contain",
|
|
14326
|
-
background: MOSAIC_BACKGROUND
|
|
14327
|
-
});
|
|
14328
|
-
const resized = await (label.length === 0 ? fitted : fitted.composite([{
|
|
14329
|
-
input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, label),
|
|
14330
|
-
top: 0,
|
|
14331
|
-
left: 0
|
|
14332
|
-
}])).jpeg({ quality: 82 }).toBuffer();
|
|
14333
|
-
decoded.push({
|
|
14334
|
-
tile,
|
|
14335
|
-
resized
|
|
14336
|
-
});
|
|
14376
|
+
await (0, sharp.default)(tile.jpeg).metadata();
|
|
14377
|
+
usable.push(tile);
|
|
14337
14378
|
} catch (err) {
|
|
14338
14379
|
logger.warn("summary mosaic: a tile could not be decoded and was dropped", {
|
|
14339
14380
|
tags: { deviceId: tile.deviceId },
|
|
@@ -14343,23 +14384,34 @@ async function renderMosaic(request, logger) {
|
|
|
14343
14384
|
}
|
|
14344
14385
|
});
|
|
14345
14386
|
}
|
|
14346
|
-
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)`);
|
|
14347
14389
|
const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
|
|
14348
14390
|
const layout = mosaicLayout({
|
|
14349
|
-
tileCount:
|
|
14391
|
+
tileCount: usable.length,
|
|
14350
14392
|
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
|
|
14351
14393
|
withTitle: caption.length > 0
|
|
14352
14394
|
});
|
|
14353
|
-
const composites = await Promise.all(
|
|
14354
|
-
|
|
14355
|
-
|
|
14356
|
-
|
|
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,
|
|
14357
14402
|
fit: "contain",
|
|
14358
14403
|
background: MOSAIC_BACKGROUND
|
|
14359
|
-
})
|
|
14360
|
-
|
|
14361
|
-
|
|
14362
|
-
|
|
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
|
+
}));
|
|
14363
14415
|
if (caption.length > 0 && layout.titleHeight > 0) composites.unshift({
|
|
14364
14416
|
input: titleSvg(layout.width, layout.titleHeight, caption),
|
|
14365
14417
|
top: 0,
|
|
@@ -14575,6 +14627,36 @@ function buildSummaryOutboxInputs(input) {
|
|
|
14575
14627
|
* the quota degrades honestly: the best cameras get their one tile, the rest
|
|
14576
14628
|
* are counted as dropped and the count rides the log line — never silently.
|
|
14577
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
|
+
*
|
|
14578
14660
|
* ## Zones are scoped PER CAMERA
|
|
14579
14661
|
*
|
|
14580
14662
|
* `filters.zones` is a flat id list on a rule that spans cameras, and zone ids
|
|
@@ -14598,15 +14680,20 @@ function dwellSeconds(candidate) {
|
|
|
14598
14680
|
* branch that discards work silently reads as "it never happened".
|
|
14599
14681
|
*/
|
|
14600
14682
|
function filterSummaryCandidates(candidates, filters, zoneOwner) {
|
|
14601
|
-
const
|
|
14602
|
-
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;
|
|
14603
14688
|
if (filters === void 0) return {
|
|
14604
14689
|
kept: [...moving],
|
|
14605
14690
|
droppedByClass: 0,
|
|
14606
14691
|
droppedByZone: 0,
|
|
14607
14692
|
droppedByImportance: 0,
|
|
14608
14693
|
droppedByDwell: 0,
|
|
14609
|
-
droppedStationary
|
|
14694
|
+
droppedStationary,
|
|
14695
|
+
droppedUnconfirmed,
|
|
14696
|
+
unknownConfirmation
|
|
14610
14697
|
};
|
|
14611
14698
|
const include = filters.classes;
|
|
14612
14699
|
const exclude = new Set(filters.classesExclude ?? []);
|
|
@@ -14652,7 +14739,9 @@ function filterSummaryCandidates(candidates, filters, zoneOwner) {
|
|
|
14652
14739
|
droppedByZone,
|
|
14653
14740
|
droppedByImportance,
|
|
14654
14741
|
droppedByDwell,
|
|
14655
|
-
droppedStationary
|
|
14742
|
+
droppedStationary,
|
|
14743
|
+
droppedUnconfirmed,
|
|
14744
|
+
unknownConfirmation
|
|
14656
14745
|
};
|
|
14657
14746
|
}
|
|
14658
14747
|
/**
|
|
@@ -15028,7 +15117,9 @@ var NcSummaryProducer = class {
|
|
|
15028
15117
|
droppedByZone: filtered.droppedByZone,
|
|
15029
15118
|
droppedByImportance: filtered.droppedByImportance,
|
|
15030
15119
|
droppedByDwell: filtered.droppedByDwell,
|
|
15031
|
-
droppedStationary: filtered.droppedStationary
|
|
15120
|
+
droppedStationary: filtered.droppedStationary,
|
|
15121
|
+
droppedUnconfirmed: filtered.droppedUnconfirmed,
|
|
15122
|
+
unknownConfirmation: filtered.unknownConfirmation
|
|
15032
15123
|
} });
|
|
15033
15124
|
if (opts.stamp) await this.deps.store.markGenerated(rule.id, window.endMs);
|
|
15034
15125
|
return {
|
|
@@ -15092,6 +15183,8 @@ var NcSummaryProducer = class {
|
|
|
15092
15183
|
scanned: candidates.length,
|
|
15093
15184
|
matched: filtered.kept.length,
|
|
15094
15185
|
droppedStationary: filtered.droppedStationary,
|
|
15186
|
+
droppedUnconfirmed: filtered.droppedUnconfirmed,
|
|
15187
|
+
unknownConfirmation: filtered.unknownConfirmation,
|
|
15095
15188
|
droppedByClass: filtered.droppedByClass,
|
|
15096
15189
|
tiles: loaded.length,
|
|
15097
15190
|
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
@@ -22251,6 +22344,16 @@ function planRuleTest(input) {
|
|
|
22251
22344
|
* never be re-owned through a patch.
|
|
22252
22345
|
* - `nc.setRuleTargetEnabled` — a user opting HIS OWN target out of a rule
|
|
22253
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.
|
|
22254
22357
|
*
|
|
22255
22358
|
* Target-ownership limits (personal-rule delivery target list + the opt-out
|
|
22256
22359
|
* target) are enforced against the notifiers target catalog; ADMINS bypass the
|
|
@@ -22469,7 +22572,25 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
22469
22572
|
auth: "admin",
|
|
22470
22573
|
caller: "required"
|
|
22471
22574
|
}),
|
|
22472
|
-
"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" }),
|
|
22473
22594
|
/**
|
|
22474
22595
|
* ONE action for create AND update, like `nc.upsertTimelapseRule`: a digest
|
|
22475
22596
|
* rule is authored as a whole (cameras + window + filters + targets) and the
|
|
@@ -22478,10 +22599,18 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
22478
22599
|
*
|
|
22479
22600
|
* `ruleId` absent ⇒ create from `rule`; present ⇒ patch.
|
|
22480
22601
|
*
|
|
22481
|
-
* `
|
|
22482
|
-
*
|
|
22483
|
-
*
|
|
22484
|
-
*
|
|
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.
|
|
22485
22614
|
*/
|
|
22486
22615
|
"nc.upsertSummaryRule": require_dist.customAction(require_dist.object({
|
|
22487
22616
|
ruleId: require_dist.string().optional(),
|
|
@@ -22489,12 +22618,10 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
22489
22618
|
patch: NcSummaryRulePatchSchema.optional()
|
|
22490
22619
|
}), require_dist.object({ rule: NcSummaryRuleSchema }), {
|
|
22491
22620
|
kind: "mutation",
|
|
22492
|
-
auth: "admin",
|
|
22493
22621
|
caller: "required"
|
|
22494
22622
|
}),
|
|
22495
22623
|
"nc.deleteSummaryRule": require_dist.customAction(require_dist.object({ ruleId: require_dist.string() }), require_dist.object({ success: require_dist.literal(true) }), {
|
|
22496
22624
|
kind: "mutation",
|
|
22497
|
-
auth: "admin",
|
|
22498
22625
|
caller: "required"
|
|
22499
22626
|
}),
|
|
22500
22627
|
/**
|
|
@@ -22588,6 +22715,17 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
22588
22715
|
* a surface that distinguishes them reports what other people own. */
|
|
22589
22716
|
"nc.getArtifact": require_dist.customAction(require_dist.object({ artifactId: require_dist.string().min(1) }), require_dist.object({ artifact: NcArtifactRecordSchema.nullable() }), { caller: "required" })
|
|
22590
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;
|
|
22591
22729
|
/** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
|
|
22592
22730
|
function requireCaller$1(caller) {
|
|
22593
22731
|
if (!caller || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error("forbidden: authenticated caller required");
|
|
@@ -22648,6 +22786,22 @@ function makeNcActionHandlers(deps) {
|
|
|
22648
22786
|
if (editor === void 0) throw new Error("this node does not host the notification text catalog — use the hub");
|
|
22649
22787
|
return editor;
|
|
22650
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
|
+
};
|
|
22651
22805
|
/** A timelapse rule the caller may EDIT. Same admin bypass as `NcRule`. */
|
|
22652
22806
|
const assertTimelapseEditable = (ruleId, caller, tl) => {
|
|
22653
22807
|
const rule = tl.store.get(ruleId);
|
|
@@ -22918,21 +23072,23 @@ function makeNcActionHandlers(deps) {
|
|
|
22918
23072
|
"nc.listSummaryRules": async (_input, caller) => {
|
|
22919
23073
|
const c = requireCaller$1(caller);
|
|
22920
23074
|
const sum = requireSummary();
|
|
22921
|
-
return {
|
|
22922
|
-
|
|
22923
|
-
|
|
22924
|
-
|
|
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
|
+
};
|
|
22925
23082
|
},
|
|
22926
23083
|
"nc.upsertSummaryRule": async (input, caller) => {
|
|
22927
23084
|
const c = requireCaller$1(caller);
|
|
22928
|
-
if (!c.isAdmin) throw new Error("forbidden: admin required to author a summary rule");
|
|
22929
23085
|
const sum = requireSummary();
|
|
22930
23086
|
if (input.ruleId === void 0) {
|
|
22931
23087
|
if (input.rule === void 0) throw new Error("upsertSummaryRule: rule or ruleId required");
|
|
22932
23088
|
const parsed = NcSummaryRuleInputSchema.parse(input.rule);
|
|
22933
23089
|
assertSummaryBudget(parsed);
|
|
22934
23090
|
await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
|
|
22935
|
-
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);
|
|
22936
23092
|
deps.logger.info("summary rule created", { meta: {
|
|
22937
23093
|
ruleId: rule.id,
|
|
22938
23094
|
owner: c.userId,
|
|
@@ -22940,8 +23096,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22940
23096
|
} });
|
|
22941
23097
|
return { rule };
|
|
22942
23098
|
}
|
|
22943
|
-
const existing =
|
|
22944
|
-
if (existing === null) throw new Error(`forbidden: summary rule not found: ${input.ruleId}`);
|
|
23099
|
+
const existing = assertSummaryEditable(input.ruleId, c, sum);
|
|
22945
23100
|
if (input.patch === void 0) throw new Error("upsertSummaryRule: patch required");
|
|
22946
23101
|
const patch = NcSummaryRulePatchSchema.parse(input.patch);
|
|
22947
23102
|
assertSummaryBudget({
|
|
@@ -22958,8 +23113,9 @@ function makeNcActionHandlers(deps) {
|
|
|
22958
23113
|
},
|
|
22959
23114
|
"nc.deleteSummaryRule": async (input, caller) => {
|
|
22960
23115
|
const c = requireCaller$1(caller);
|
|
22961
|
-
|
|
22962
|
-
|
|
23116
|
+
const sum = requireSummary();
|
|
23117
|
+
assertSummaryEditable(input.ruleId, c, sum);
|
|
23118
|
+
await sum.store.delete(input.ruleId);
|
|
22963
23119
|
deps.logger.info("summary rule deleted", { meta: {
|
|
22964
23120
|
ruleId: input.ruleId,
|
|
22965
23121
|
owner: c.userId
|
|
@@ -44276,7 +44432,9 @@ var TrackStore = class {
|
|
|
44276
44432
|
*/
|
|
44277
44433
|
rowToTrack(id, data, projection) {
|
|
44278
44434
|
const slim = projection === "slim";
|
|
44279
|
-
const
|
|
44435
|
+
const storedPositions = data["positions"] ?? [];
|
|
44436
|
+
const observations = storedPositions.length;
|
|
44437
|
+
const positions = slim ? [] : storedPositions;
|
|
44280
44438
|
const snapshots = slim ? [] : data["snapshots"] ?? [];
|
|
44281
44439
|
const zones = data["zonesVisited"] ?? [];
|
|
44282
44440
|
const classes = data["classes"];
|
|
@@ -44333,6 +44491,7 @@ var TrackStore = class {
|
|
|
44333
44491
|
...typeof hasFace === "boolean" ? { hasFace } : {},
|
|
44334
44492
|
...typeof hasRider === "boolean" ? { hasRider } : {},
|
|
44335
44493
|
...closedReason !== void 0 ? { closedReason } : {},
|
|
44494
|
+
observations,
|
|
44336
44495
|
...envelope !== null ? { envelope } : {}
|
|
44337
44496
|
};
|
|
44338
44497
|
}
|
|
@@ -52642,13 +52801,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
52642
52801
|
buildSummaryPorts(stores) {
|
|
52643
52802
|
return {
|
|
52644
52803
|
listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
|
|
52645
|
-
|
|
52804
|
+
const tracks = await stores.trackStore.queryHistorical({
|
|
52646
52805
|
deviceId,
|
|
52647
52806
|
since: sinceMs,
|
|
52648
52807
|
until: untilMs,
|
|
52649
52808
|
projection: "slim",
|
|
52650
52809
|
limit
|
|
52651
|
-
})
|
|
52810
|
+
});
|
|
52811
|
+
const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
|
|
52812
|
+
return tracks.map((t) => ({
|
|
52652
52813
|
trackId: t.trackId,
|
|
52653
52814
|
deviceId: t.deviceId,
|
|
52654
52815
|
className: t.className,
|
|
@@ -52657,7 +52818,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
52657
52818
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
52658
52819
|
zones: [...t.zonesVisited],
|
|
52659
52820
|
...t.classes !== void 0 ? { classes: [...t.classes] } : {},
|
|
52660
|
-
...t.closedReason === "stationary-promotion" ? { stationary: true } : {}
|
|
52821
|
+
...t.closedReason === "stationary-promotion" ? { stationary: true } : {},
|
|
52822
|
+
...t.observations !== void 0 ? { confirmed: t.observations >= minHits } : {}
|
|
52661
52823
|
}));
|
|
52662
52824
|
},
|
|
52663
52825
|
listMediaInfo: async (trackId) => {
|
|
@@ -14199,7 +14199,11 @@ function mosaicTextPlan(captions) {
|
|
|
14199
14199
|
};
|
|
14200
14200
|
}
|
|
14201
14201
|
/** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
|
|
14202
|
-
* by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip.
|
|
14202
|
+
* by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip.
|
|
14203
|
+
*
|
|
14204
|
+
* `rows` is the load-bearing half — it is how many rows {@link mosaicRowCounts}
|
|
14205
|
+
* distributes into. `cols` describes the WIDEST row, and is no longer a cell
|
|
14206
|
+
* count: see {@link mosaicRowCounts} for why a `cols × rows` grid was the bug. */
|
|
14203
14207
|
function mosaicGrid(n) {
|
|
14204
14208
|
const count = Math.max(1, Math.floor(n));
|
|
14205
14209
|
const cols = Math.ceil(Math.sqrt(count));
|
|
@@ -14209,31 +14213,84 @@ function mosaicGrid(n) {
|
|
|
14209
14213
|
};
|
|
14210
14214
|
}
|
|
14211
14215
|
/**
|
|
14216
|
+
* How many tiles each ROW carries, top to bottom — and the reason the mosaic
|
|
14217
|
+
* can no longer have a hole in it.
|
|
14218
|
+
*
|
|
14219
|
+
* The layout used to be a `cols × rows` GRID, and a grid holds more cells than
|
|
14220
|
+
* tiles for every count that is not a perfect fit: 3 into a 2×2, 5 into a 3×2,
|
|
14221
|
+
* 7 and 8 into a 3×3. The composite only ever wrote the tiles, so the spare
|
|
14222
|
+
* cells stayed the canvas — `MOSAIC_BACKGROUND`, rgb 17 — and the operator's
|
|
14223
|
+
* three-tile digest was a quarter black. Measured on the shipped renderer:
|
|
14224
|
+
* 24.8% of a three-tile mosaic was background. His words: *"se ci sono 3
|
|
14225
|
+
* elementi non dovrebbe renderizzare buchi neri"*.
|
|
14226
|
+
*
|
|
14227
|
+
* Counting ROWS instead removes the failure by construction rather than by
|
|
14228
|
+
* patching it. Every row spans the full width and divides it among the tiles it
|
|
14229
|
+
* actually has, so a row is full whatever the remainder and there is no such
|
|
14230
|
+
* thing as a spare cell. The shapes fall out of it, and they are the ones asked
|
|
14231
|
+
* for: 1 ⇒ full frame, 2 ⇒ side by side, 3 ⇒ one big over two small, 4 ⇒ 2×2.
|
|
14232
|
+
*
|
|
14233
|
+
* **The fewer-tiled row comes FIRST.** Tiles arrive in the selection's order,
|
|
14234
|
+
* so row 0 is the head of the ranking; putting the remainder at the top would
|
|
14235
|
+
* make the WORST tile the big one.
|
|
14236
|
+
*
|
|
14237
|
+
* Every cell keeps {@link TILE_ASPECT}, so rows differ in height — which is
|
|
14238
|
+
* also what stops the letterbox: a full-width hero tile is 16:9 exactly, not a
|
|
14239
|
+
* 16:9 picture centred in a double-height cell with bars over and under it.
|
|
14240
|
+
*/
|
|
14241
|
+
function mosaicRowCounts(n) {
|
|
14242
|
+
const count = Math.max(1, Math.floor(n));
|
|
14243
|
+
const { rows } = mosaicGrid(count);
|
|
14244
|
+
const base = Math.floor(count / rows);
|
|
14245
|
+
const remainder = count % rows;
|
|
14246
|
+
const counts = [];
|
|
14247
|
+
for (let row = 0; row < rows; row += 1) counts.push(row < rows - remainder ? base : base + 1);
|
|
14248
|
+
return counts;
|
|
14249
|
+
}
|
|
14250
|
+
/**
|
|
14212
14251
|
* Where every tile lands. Pure — no bytes, no sharp, no clock.
|
|
14213
14252
|
*
|
|
14214
14253
|
* Exported because the rectangles are DATA the artefact should carry: a viewer
|
|
14215
14254
|
* that wants a tappable mosaic reads these rather than re-deriving a layout
|
|
14216
14255
|
* that could drift from the one actually rendered.
|
|
14256
|
+
*
|
|
14257
|
+
* The rectangles COVER the picture area exactly — that is the property the
|
|
14258
|
+
* spec asserts, and it is the one the old grid broke.
|
|
14217
14259
|
*/
|
|
14218
14260
|
function mosaicLayout(input) {
|
|
14219
|
-
const
|
|
14220
|
-
const
|
|
14221
|
-
const
|
|
14261
|
+
const rowCounts = mosaicRowCounts(input.tileCount);
|
|
14262
|
+
const width = Math.max(1, Math.floor(input.maxWidth ?? 1920));
|
|
14263
|
+
const firstRowCount = rowCounts[0] ?? 1;
|
|
14264
|
+
const tileWidth = Math.floor(width / firstRowCount);
|
|
14222
14265
|
const tileHeight = Math.round(tileWidth * TILE_ASPECT);
|
|
14223
14266
|
const titleHeight = input.withTitle === true ? Math.round(tileHeight * TITLE_BAND_FRACTION) : 0;
|
|
14224
14267
|
const tiles = [];
|
|
14225
|
-
|
|
14226
|
-
|
|
14227
|
-
|
|
14228
|
-
|
|
14229
|
-
|
|
14230
|
-
|
|
14231
|
-
|
|
14268
|
+
let y = titleHeight;
|
|
14269
|
+
let index = 0;
|
|
14270
|
+
for (const count of rowCounts) {
|
|
14271
|
+
const nominalWidth = Math.floor(width / count);
|
|
14272
|
+
const rowHeight = Math.round(nominalWidth * TILE_ASPECT);
|
|
14273
|
+
let x = 0;
|
|
14274
|
+
for (let inRow = 0; inRow < count; inRow += 1) {
|
|
14275
|
+
const w = inRow === count - 1 ? width - x : nominalWidth;
|
|
14276
|
+
tiles.push({
|
|
14277
|
+
index,
|
|
14278
|
+
x,
|
|
14279
|
+
y,
|
|
14280
|
+
w,
|
|
14281
|
+
h: rowHeight
|
|
14282
|
+
});
|
|
14283
|
+
x += w;
|
|
14284
|
+
index += 1;
|
|
14285
|
+
}
|
|
14286
|
+
y += rowHeight;
|
|
14287
|
+
}
|
|
14232
14288
|
return {
|
|
14233
|
-
width
|
|
14234
|
-
height:
|
|
14235
|
-
cols,
|
|
14236
|
-
rows,
|
|
14289
|
+
width,
|
|
14290
|
+
height: y,
|
|
14291
|
+
cols: Math.max(...rowCounts),
|
|
14292
|
+
rows: rowCounts.length,
|
|
14293
|
+
rowCounts,
|
|
14237
14294
|
tileWidth,
|
|
14238
14295
|
tileHeight,
|
|
14239
14296
|
titleHeight,
|
|
@@ -14297,36 +14354,20 @@ function titleSvg(width, height, caption) {
|
|
|
14297
14354
|
*
|
|
14298
14355
|
* A tile sharp cannot read is DROPPED and counted, never composited as a blank
|
|
14299
14356
|
* cell — a black square in a digest reads as "the camera saw nothing", which is
|
|
14300
|
-
* the opposite of what happened. The
|
|
14301
|
-
*
|
|
14357
|
+
* the opposite of what happened. The layout is computed from the SURVIVORS, and
|
|
14358
|
+
* because {@link mosaicRowCounts} counts rows rather than grid cells there is no
|
|
14359
|
+
* remainder to leave empty either.
|
|
14302
14360
|
*
|
|
14303
|
-
* @throws
|
|
14304
|
-
* treats
|
|
14361
|
+
* @throws when every tile was dropped, and when sharp cannot produce the final
|
|
14362
|
+
* canvas at all; the caller treats both as "no mosaic" and still
|
|
14363
|
+
* delivers the counts.
|
|
14305
14364
|
*/
|
|
14306
14365
|
async function renderMosaic(request, logger) {
|
|
14307
14366
|
const plan = mosaicTextPlan(request.captions);
|
|
14308
|
-
const
|
|
14309
|
-
const probeLayout = mosaicLayout({
|
|
14310
|
-
tileCount: Math.max(1, request.tiles.length),
|
|
14311
|
-
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {}
|
|
14312
|
-
});
|
|
14367
|
+
const usable = [];
|
|
14313
14368
|
for (const tile of request.tiles) try {
|
|
14314
|
-
|
|
14315
|
-
|
|
14316
|
-
width: probeLayout.tileWidth,
|
|
14317
|
-
height: probeLayout.tileHeight,
|
|
14318
|
-
fit: "contain",
|
|
14319
|
-
background: MOSAIC_BACKGROUND
|
|
14320
|
-
});
|
|
14321
|
-
const resized = await (label.length === 0 ? fitted : fitted.composite([{
|
|
14322
|
-
input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, label),
|
|
14323
|
-
top: 0,
|
|
14324
|
-
left: 0
|
|
14325
|
-
}])).jpeg({ quality: 82 }).toBuffer();
|
|
14326
|
-
decoded.push({
|
|
14327
|
-
tile,
|
|
14328
|
-
resized
|
|
14329
|
-
});
|
|
14369
|
+
await sharp(tile.jpeg).metadata();
|
|
14370
|
+
usable.push(tile);
|
|
14330
14371
|
} catch (err) {
|
|
14331
14372
|
logger.warn("summary mosaic: a tile could not be decoded and was dropped", {
|
|
14332
14373
|
tags: { deviceId: tile.deviceId },
|
|
@@ -14336,23 +14377,34 @@ async function renderMosaic(request, logger) {
|
|
|
14336
14377
|
}
|
|
14337
14378
|
});
|
|
14338
14379
|
}
|
|
14339
|
-
const droppedTiles = request.tiles.length -
|
|
14380
|
+
const droppedTiles = request.tiles.length - usable.length;
|
|
14381
|
+
if (usable.length === 0) throw new Error(`summary mosaic: no tile could be decoded (${request.tiles.length} offered)`);
|
|
14340
14382
|
const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
|
|
14341
14383
|
const layout = mosaicLayout({
|
|
14342
|
-
tileCount:
|
|
14384
|
+
tileCount: usable.length,
|
|
14343
14385
|
...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
|
|
14344
14386
|
withTitle: caption.length > 0
|
|
14345
14387
|
});
|
|
14346
|
-
const composites = await Promise.all(
|
|
14347
|
-
|
|
14348
|
-
|
|
14349
|
-
|
|
14388
|
+
const composites = await Promise.all(usable.map(async (tile, index) => {
|
|
14389
|
+
const rect = layout.tiles[index];
|
|
14390
|
+
if (rect === void 0) throw new Error("summary mosaic: layout lost a tile");
|
|
14391
|
+
const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
|
|
14392
|
+
const fitted = sharp(tile.jpeg).resize({
|
|
14393
|
+
width: rect.w,
|
|
14394
|
+
height: rect.h,
|
|
14350
14395
|
fit: "contain",
|
|
14351
14396
|
background: MOSAIC_BACKGROUND
|
|
14352
|
-
})
|
|
14353
|
-
|
|
14354
|
-
|
|
14355
|
-
|
|
14397
|
+
});
|
|
14398
|
+
return {
|
|
14399
|
+
input: await (label.length === 0 ? fitted : fitted.composite([{
|
|
14400
|
+
input: tileLabelSvg(rect.w, rect.h, label),
|
|
14401
|
+
top: 0,
|
|
14402
|
+
left: 0
|
|
14403
|
+
}])).jpeg({ quality: 82 }).toBuffer(),
|
|
14404
|
+
top: rect.y,
|
|
14405
|
+
left: rect.x
|
|
14406
|
+
};
|
|
14407
|
+
}));
|
|
14356
14408
|
if (caption.length > 0 && layout.titleHeight > 0) composites.unshift({
|
|
14357
14409
|
input: titleSvg(layout.width, layout.titleHeight, caption),
|
|
14358
14410
|
top: 0,
|
|
@@ -14568,6 +14620,36 @@ function buildSummaryOutboxInputs(input) {
|
|
|
14568
14620
|
* the quota degrades honestly: the best cameras get their one tile, the rest
|
|
14569
14621
|
* are counted as dropped and the count rides the log line — never silently.
|
|
14570
14622
|
*
|
|
14623
|
+
* ## Only tracks the pipeline PROMOTED
|
|
14624
|
+
*
|
|
14625
|
+
* A digest is retrospective. The operator's own framing, verbatim: *"ideale
|
|
14626
|
+
* sarebbe considerare solo tracce che vengono alla fine promosse a tracce, dato
|
|
14627
|
+
* che il summary non è atteso di essere realtime"* — the window is closed
|
|
14628
|
+
* before this function is called, so the selection may wait for the pipeline's
|
|
14629
|
+
* verdict instead of guessing at it.
|
|
14630
|
+
*
|
|
14631
|
+
* The verdict is `SortTracker`'s own promotion threshold: a candidate is not
|
|
14632
|
+
* emitted as a track until it has been matched on `minHits` frames
|
|
14633
|
+
* (`isConfirmedForEmit`, per-device setting, *"minimum consecutive detection
|
|
14634
|
+
* hits before a track is promoted to output"*). The gate here re-applies that
|
|
14635
|
+
* same threshold to what the CLOSED row actually left behind — the count of
|
|
14636
|
+
* recorded sightings on its trajectory — and the port that builds a candidate
|
|
14637
|
+
* is the only thing that knows the device's `minHits`.
|
|
14638
|
+
*
|
|
14639
|
+
* This is not a movement heuristic and deliberately so: `totalDistance` and
|
|
14640
|
+
* `state` were both measured against live traffic and both hide real passages.
|
|
14641
|
+
* What they cannot see is the shape this gate is for — a row whose whole life
|
|
14642
|
+
* is one or two frames. Measured on device 617 (2026-08-19 04:12): two rows,
|
|
14643
|
+
* `closedReason` NULL so the parked pass is blind to them, dwell 53 ms,
|
|
14644
|
+
* `totalDistance` 0, two recorded sightings each. And `minImportance` cannot
|
|
14645
|
+
* cover it either, structurally: the static-track gate that demotes a frozen
|
|
14646
|
+
* subject needs two centroids to measure anything
|
|
14647
|
+
* (`computeStaticTrackMetrics`), so a single-sighting row is exactly the shape
|
|
14648
|
+
* it never scores down — 33 of the live hub's 34 such rows sit at or above 0.2.
|
|
14649
|
+
*
|
|
14650
|
+
* There is no rule field to turn it off. A digest of things that were never
|
|
14651
|
+
* tracks has no audience, and this repo has shipped knobs nobody read.
|
|
14652
|
+
*
|
|
14571
14653
|
* ## Zones are scoped PER CAMERA
|
|
14572
14654
|
*
|
|
14573
14655
|
* `filters.zones` is a flat id list on a rule that spans cameras, and zone ids
|
|
@@ -14591,15 +14673,20 @@ function dwellSeconds(candidate) {
|
|
|
14591
14673
|
* branch that discards work silently reads as "it never happened".
|
|
14592
14674
|
*/
|
|
14593
14675
|
function filterSummaryCandidates(candidates, filters, zoneOwner) {
|
|
14594
|
-
const
|
|
14595
|
-
const
|
|
14676
|
+
const promoted = candidates.filter((candidate) => candidate.confirmed !== false);
|
|
14677
|
+
const droppedUnconfirmed = candidates.length - promoted.length;
|
|
14678
|
+
const unknownConfirmation = promoted.filter((candidate) => candidate.confirmed === void 0).length;
|
|
14679
|
+
const moving = filters?.includeStationary === true ? promoted : promoted.filter((candidate) => candidate.stationary !== true);
|
|
14680
|
+
const droppedStationary = promoted.length - moving.length;
|
|
14596
14681
|
if (filters === void 0) return {
|
|
14597
14682
|
kept: [...moving],
|
|
14598
14683
|
droppedByClass: 0,
|
|
14599
14684
|
droppedByZone: 0,
|
|
14600
14685
|
droppedByImportance: 0,
|
|
14601
14686
|
droppedByDwell: 0,
|
|
14602
|
-
droppedStationary
|
|
14687
|
+
droppedStationary,
|
|
14688
|
+
droppedUnconfirmed,
|
|
14689
|
+
unknownConfirmation
|
|
14603
14690
|
};
|
|
14604
14691
|
const include = filters.classes;
|
|
14605
14692
|
const exclude = new Set(filters.classesExclude ?? []);
|
|
@@ -14645,7 +14732,9 @@ function filterSummaryCandidates(candidates, filters, zoneOwner) {
|
|
|
14645
14732
|
droppedByZone,
|
|
14646
14733
|
droppedByImportance,
|
|
14647
14734
|
droppedByDwell,
|
|
14648
|
-
droppedStationary
|
|
14735
|
+
droppedStationary,
|
|
14736
|
+
droppedUnconfirmed,
|
|
14737
|
+
unknownConfirmation
|
|
14649
14738
|
};
|
|
14650
14739
|
}
|
|
14651
14740
|
/**
|
|
@@ -15021,7 +15110,9 @@ var NcSummaryProducer = class {
|
|
|
15021
15110
|
droppedByZone: filtered.droppedByZone,
|
|
15022
15111
|
droppedByImportance: filtered.droppedByImportance,
|
|
15023
15112
|
droppedByDwell: filtered.droppedByDwell,
|
|
15024
|
-
droppedStationary: filtered.droppedStationary
|
|
15113
|
+
droppedStationary: filtered.droppedStationary,
|
|
15114
|
+
droppedUnconfirmed: filtered.droppedUnconfirmed,
|
|
15115
|
+
unknownConfirmation: filtered.unknownConfirmation
|
|
15025
15116
|
} });
|
|
15026
15117
|
if (opts.stamp) await this.deps.store.markGenerated(rule.id, window.endMs);
|
|
15027
15118
|
return {
|
|
@@ -15085,6 +15176,8 @@ var NcSummaryProducer = class {
|
|
|
15085
15176
|
scanned: candidates.length,
|
|
15086
15177
|
matched: filtered.kept.length,
|
|
15087
15178
|
droppedStationary: filtered.droppedStationary,
|
|
15179
|
+
droppedUnconfirmed: filtered.droppedUnconfirmed,
|
|
15180
|
+
unknownConfirmation: filtered.unknownConfirmation,
|
|
15088
15181
|
droppedByClass: filtered.droppedByClass,
|
|
15089
15182
|
tiles: loaded.length,
|
|
15090
15183
|
cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
|
|
@@ -22229,6 +22322,16 @@ function planRuleTest(input) {
|
|
|
22229
22322
|
* never be re-owned through a patch.
|
|
22230
22323
|
* - `nc.setRuleTargetEnabled` — a user opting HIS OWN target out of a rule
|
|
22231
22324
|
* VISIBLE to him (his own personal rule OR a global/admin rule).
|
|
22325
|
+
* - the TIMELAPSE and DIGEST families (`nc.*TimelapseRule*`,
|
|
22326
|
+
* `nc.*SummaryRule*`) repeat that model exactly over their own stores. The
|
|
22327
|
+
* digest half was `auth: 'admin'` in v1 and was relaxed once the operator
|
|
22328
|
+
* closed the cost question; `nc.listSummaryRules` reports the current answer
|
|
22329
|
+
* as `canAuthor` so a client talking to an older hub degrades to a read-only
|
|
22330
|
+
* list instead of discovering the refusal as a 403 on a filled-in form.
|
|
22331
|
+
* - the three TEST actions (`nc.injectTestEvent`, `nc.testRule`,
|
|
22332
|
+
* `nc.testTimelapseRule`, `nc.testSummaryRule`) stay `auth: 'admin'`
|
|
22333
|
+
* regardless of who owns the rule: they DELIVER, to every target the rule
|
|
22334
|
+
* addresses, on other people's phones.
|
|
22232
22335
|
*
|
|
22233
22336
|
* Target-ownership limits (personal-rule delivery target list + the opt-out
|
|
22234
22337
|
* target) are enforced against the notifiers target catalog; ADMINS bypass the
|
|
@@ -22447,7 +22550,25 @@ var ncActions = defineCustomActions({
|
|
|
22447
22550
|
auth: "admin",
|
|
22448
22551
|
caller: "required"
|
|
22449
22552
|
}),
|
|
22450
|
-
"nc.listSummaryRules": customAction(object({}), object({
|
|
22553
|
+
"nc.listSummaryRules": customAction(object({}), object({
|
|
22554
|
+
rules: array(NcViewerSummaryRuleSchema),
|
|
22555
|
+
/**
|
|
22556
|
+
* DOES THIS HUB LET THE CALLER AUTHOR ONE? The capability probe, and the
|
|
22557
|
+
* only honest way a client can know.
|
|
22558
|
+
*
|
|
22559
|
+
* `nc.upsertSummaryRule` was `auth: 'admin'` until the operator closed
|
|
22560
|
+
* that decision, and a viewer talking to a hub that still refuses has to
|
|
22561
|
+
* find out BEFORE it offers a "New summary rule" button — otherwise the
|
|
22562
|
+
* only feedback is a 403 taken in front of the operator on a form they
|
|
22563
|
+
* had already filled in. ADDITIVE and therefore `.optional()` on every
|
|
22564
|
+
* client mirror: absent means an older hub, which the viewer reads as "no
|
|
22565
|
+
* authoring" and degrades to a read-only list with a note.
|
|
22566
|
+
*
|
|
22567
|
+
* Derived from the same predicate the handlers enforce — never a constant
|
|
22568
|
+
* `true` — so a hub that re-tightened the auth could not say otherwise.
|
|
22569
|
+
*/
|
|
22570
|
+
canAuthor: boolean()
|
|
22571
|
+
}), { caller: "required" }),
|
|
22451
22572
|
/**
|
|
22452
22573
|
* ONE action for create AND update, like `nc.upsertTimelapseRule`: a digest
|
|
22453
22574
|
* rule is authored as a whole (cameras + window + filters + targets) and the
|
|
@@ -22456,10 +22577,18 @@ var ncActions = defineCustomActions({
|
|
|
22456
22577
|
*
|
|
22457
22578
|
* `ruleId` absent ⇒ create from `rule`; present ⇒ patch.
|
|
22458
22579
|
*
|
|
22459
|
-
* `
|
|
22460
|
-
*
|
|
22461
|
-
*
|
|
22462
|
-
*
|
|
22580
|
+
* `caller: 'required'`, NOT `auth: 'admin'`. It was admin-only in v1 because
|
|
22581
|
+
* a digest spans cameras and consumes the local vision model, and that cost
|
|
22582
|
+
* had not been watched yet. The operator closed the decision, so this now
|
|
22583
|
+
* carries exactly the auth the two rule families beside it carry: ownership
|
|
22584
|
+
* is enforced in the handler, `ownerUserId` is STAMPED from the resolved
|
|
22585
|
+
* caller, a personal rule may deliver only to targets that caller OWNS, and an
|
|
22586
|
+
* admin still authors GLOBAL rules. No new ownership model was invented for
|
|
22587
|
+
* it — `NcSummaryStore` has had `ownerUserId` / `listForOwner` since it was
|
|
22588
|
+
* written and they were simply not reachable.
|
|
22589
|
+
*
|
|
22590
|
+
* `nc.testSummaryRule` stays admin: that one DELIVERS, on other people's
|
|
22591
|
+
* phones.
|
|
22463
22592
|
*/
|
|
22464
22593
|
"nc.upsertSummaryRule": customAction(object({
|
|
22465
22594
|
ruleId: string().optional(),
|
|
@@ -22467,12 +22596,10 @@ var ncActions = defineCustomActions({
|
|
|
22467
22596
|
patch: NcSummaryRulePatchSchema.optional()
|
|
22468
22597
|
}), object({ rule: NcSummaryRuleSchema }), {
|
|
22469
22598
|
kind: "mutation",
|
|
22470
|
-
auth: "admin",
|
|
22471
22599
|
caller: "required"
|
|
22472
22600
|
}),
|
|
22473
22601
|
"nc.deleteSummaryRule": customAction(object({ ruleId: string() }), object({ success: literal(true) }), {
|
|
22474
22602
|
kind: "mutation",
|
|
22475
|
-
auth: "admin",
|
|
22476
22603
|
caller: "required"
|
|
22477
22604
|
}),
|
|
22478
22605
|
/**
|
|
@@ -22566,6 +22693,17 @@ var ncActions = defineCustomActions({
|
|
|
22566
22693
|
* a surface that distinguishes them reports what other people own. */
|
|
22567
22694
|
"nc.getArtifact": customAction(object({ artifactId: string().min(1) }), object({ artifact: NcArtifactRecordSchema.nullable() }), { caller: "required" })
|
|
22568
22695
|
});
|
|
22696
|
+
/**
|
|
22697
|
+
* Does THIS build let a non-admin author a digest rule?
|
|
22698
|
+
*
|
|
22699
|
+
* A named constant rather than a literal `true` at the call site, so the one
|
|
22700
|
+
* fact `nc.listSummaryRules.canAuthor` reports and the auth the upsert actually
|
|
22701
|
+
* carries sit beside each other and are changed together. If the operator ever
|
|
22702
|
+
* re-tightens the action, this goes to `false` in the same edit and the viewer
|
|
22703
|
+
* degrades on the next list — instead of the app discovering it by taking a 403
|
|
22704
|
+
* on a form the operator had already filled in.
|
|
22705
|
+
*/
|
|
22706
|
+
var SUMMARY_AUTHORING_OPEN = true;
|
|
22569
22707
|
/** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
|
|
22570
22708
|
function requireCaller$1(caller) {
|
|
22571
22709
|
if (!caller || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error("forbidden: authenticated caller required");
|
|
@@ -22626,6 +22764,22 @@ function makeNcActionHandlers(deps) {
|
|
|
22626
22764
|
if (editor === void 0) throw new Error("this node does not host the notification text catalog — use the hub");
|
|
22627
22765
|
return editor;
|
|
22628
22766
|
};
|
|
22767
|
+
/**
|
|
22768
|
+
* A digest rule the caller may EDIT. Same shape and same admin bypass as
|
|
22769
|
+
* {@link assertTimelapseEditable} — deliberately, because a second ownership
|
|
22770
|
+
* model for the third rule family is a second place to get it wrong.
|
|
22771
|
+
*
|
|
22772
|
+
* A GLOBAL rule (no `ownerUserId`, what an admin authors) is VISIBLE to
|
|
22773
|
+
* everyone and editable by admins only: `listForOwner` returns it, so a user
|
|
22774
|
+
* sees it in the list, and `readOnly` says so.
|
|
22775
|
+
*/
|
|
22776
|
+
const assertSummaryEditable = (ruleId, caller, sum) => {
|
|
22777
|
+
const rule = sum.store.get(ruleId);
|
|
22778
|
+
if (rule === null) throw new Error(`forbidden: summary rule not found: ${ruleId}`);
|
|
22779
|
+
if (caller.isAdmin) return rule;
|
|
22780
|
+
if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: summary rule not owned: ${ruleId}`);
|
|
22781
|
+
return rule;
|
|
22782
|
+
};
|
|
22629
22783
|
/** A timelapse rule the caller may EDIT. Same admin bypass as `NcRule`. */
|
|
22630
22784
|
const assertTimelapseEditable = (ruleId, caller, tl) => {
|
|
22631
22785
|
const rule = tl.store.get(ruleId);
|
|
@@ -22896,21 +23050,23 @@ function makeNcActionHandlers(deps) {
|
|
|
22896
23050
|
"nc.listSummaryRules": async (_input, caller) => {
|
|
22897
23051
|
const c = requireCaller$1(caller);
|
|
22898
23052
|
const sum = requireSummary();
|
|
22899
|
-
return {
|
|
22900
|
-
|
|
22901
|
-
|
|
22902
|
-
|
|
23053
|
+
return {
|
|
23054
|
+
rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
|
|
23055
|
+
...r,
|
|
23056
|
+
readOnly: !c.isAdmin && r.ownerUserId !== c.userId
|
|
23057
|
+
})),
|
|
23058
|
+
canAuthor: SUMMARY_AUTHORING_OPEN
|
|
23059
|
+
};
|
|
22903
23060
|
},
|
|
22904
23061
|
"nc.upsertSummaryRule": async (input, caller) => {
|
|
22905
23062
|
const c = requireCaller$1(caller);
|
|
22906
|
-
if (!c.isAdmin) throw new Error("forbidden: admin required to author a summary rule");
|
|
22907
23063
|
const sum = requireSummary();
|
|
22908
23064
|
if (input.ruleId === void 0) {
|
|
22909
23065
|
if (input.rule === void 0) throw new Error("upsertSummaryRule: rule or ruleId required");
|
|
22910
23066
|
const parsed = NcSummaryRuleInputSchema.parse(input.rule);
|
|
22911
23067
|
assertSummaryBudget(parsed);
|
|
22912
23068
|
await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
|
|
22913
|
-
const rule = await sum.store.create(parsed, c.userId);
|
|
23069
|
+
const rule = await sum.store.create(parsed, c.userId, c.isAdmin ? void 0 : c.userId);
|
|
22914
23070
|
deps.logger.info("summary rule created", { meta: {
|
|
22915
23071
|
ruleId: rule.id,
|
|
22916
23072
|
owner: c.userId,
|
|
@@ -22918,8 +23074,7 @@ function makeNcActionHandlers(deps) {
|
|
|
22918
23074
|
} });
|
|
22919
23075
|
return { rule };
|
|
22920
23076
|
}
|
|
22921
|
-
const existing =
|
|
22922
|
-
if (existing === null) throw new Error(`forbidden: summary rule not found: ${input.ruleId}`);
|
|
23077
|
+
const existing = assertSummaryEditable(input.ruleId, c, sum);
|
|
22923
23078
|
if (input.patch === void 0) throw new Error("upsertSummaryRule: patch required");
|
|
22924
23079
|
const patch = NcSummaryRulePatchSchema.parse(input.patch);
|
|
22925
23080
|
assertSummaryBudget({
|
|
@@ -22936,8 +23091,9 @@ function makeNcActionHandlers(deps) {
|
|
|
22936
23091
|
},
|
|
22937
23092
|
"nc.deleteSummaryRule": async (input, caller) => {
|
|
22938
23093
|
const c = requireCaller$1(caller);
|
|
22939
|
-
|
|
22940
|
-
|
|
23094
|
+
const sum = requireSummary();
|
|
23095
|
+
assertSummaryEditable(input.ruleId, c, sum);
|
|
23096
|
+
await sum.store.delete(input.ruleId);
|
|
22941
23097
|
deps.logger.info("summary rule deleted", { meta: {
|
|
22942
23098
|
ruleId: input.ruleId,
|
|
22943
23099
|
owner: c.userId
|
|
@@ -44216,7 +44372,9 @@ var TrackStore = class {
|
|
|
44216
44372
|
*/
|
|
44217
44373
|
rowToTrack(id, data, projection) {
|
|
44218
44374
|
const slim = projection === "slim";
|
|
44219
|
-
const
|
|
44375
|
+
const storedPositions = data["positions"] ?? [];
|
|
44376
|
+
const observations = storedPositions.length;
|
|
44377
|
+
const positions = slim ? [] : storedPositions;
|
|
44220
44378
|
const snapshots = slim ? [] : data["snapshots"] ?? [];
|
|
44221
44379
|
const zones = data["zonesVisited"] ?? [];
|
|
44222
44380
|
const classes = data["classes"];
|
|
@@ -44273,6 +44431,7 @@ var TrackStore = class {
|
|
|
44273
44431
|
...typeof hasFace === "boolean" ? { hasFace } : {},
|
|
44274
44432
|
...typeof hasRider === "boolean" ? { hasRider } : {},
|
|
44275
44433
|
...closedReason !== void 0 ? { closedReason } : {},
|
|
44434
|
+
observations,
|
|
44276
44435
|
...envelope !== null ? { envelope } : {}
|
|
44277
44436
|
};
|
|
44278
44437
|
}
|
|
@@ -52582,13 +52741,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
52582
52741
|
buildSummaryPorts(stores) {
|
|
52583
52742
|
return {
|
|
52584
52743
|
listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
|
|
52585
|
-
|
|
52744
|
+
const tracks = await stores.trackStore.queryHistorical({
|
|
52586
52745
|
deviceId,
|
|
52587
52746
|
since: sinceMs,
|
|
52588
52747
|
until: untilMs,
|
|
52589
52748
|
projection: "slim",
|
|
52590
52749
|
limit
|
|
52591
|
-
})
|
|
52750
|
+
});
|
|
52751
|
+
const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
|
|
52752
|
+
return tracks.map((t) => ({
|
|
52592
52753
|
trackId: t.trackId,
|
|
52593
52754
|
deviceId: t.deviceId,
|
|
52594
52755
|
className: t.className,
|
|
@@ -52597,7 +52758,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
52597
52758
|
...t.importance !== void 0 ? { importance: t.importance } : {},
|
|
52598
52759
|
zones: [...t.zonesVisited],
|
|
52599
52760
|
...t.classes !== void 0 ? { classes: [...t.classes] } : {},
|
|
52600
|
-
...t.closedReason === "stationary-promotion" ? { stationary: true } : {}
|
|
52761
|
+
...t.closedReason === "stationary-promotion" ? { stationary: true } : {},
|
|
52762
|
+
...t.observations !== void 0 ? { confirmed: t.observations >= minHits } : {}
|
|
52601
52763
|
}));
|
|
52602
52764
|
},
|
|
52603
52765
|
listMediaInfo: async (trackId) => {
|
|
@@ -30,7 +30,7 @@ async function d(e) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
async function f() {
|
|
33
|
-
return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-
|
|
33
|
+
return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DyXoKV7Z.mjs")).catch((e) => {
|
|
34
34
|
throw l = void 0, e;
|
|
35
35
|
}), l;
|
|
36
36
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-post-analysis",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.97",
|
|
4
4
|
"description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|