@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.
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.89",
21
+ version: "1.2.90",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_pipeline_analytics_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.89",
39
+ version: "1.2.90",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -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 { cols, rows } = mosaicGrid(input.tileCount);
14227
- const maxWidth = input.maxWidth ?? 1920;
14228
- const tileWidth = Math.max(160, Math.floor(maxWidth / cols));
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
- for (let index = 0; index < Math.max(1, input.tileCount); index += 1) tiles.push({
14233
- index,
14234
- x: index % cols * tileWidth,
14235
- y: titleHeight + Math.floor(index / cols) * tileHeight,
14236
- w: tileWidth,
14237
- h: tileHeight
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: cols * tileWidth,
14241
- height: titleHeight + rows * tileHeight,
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 grid is recomputed from the survivors so
14308
- * the mosaic has no hole.
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 only if sharp cannot produce the final canvas at all; the caller
14311
- * treats that as "no mosaic" and still delivers the counts.
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 decoded = [];
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
- const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
14322
- const fitted = (0, sharp.default)(tile.jpeg).resize({
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 - decoded.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: Math.max(1, decoded.length),
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(decoded.map(async ({ resized }, index) => ({
14354
- input: layout.tileWidth === probeLayout.tileWidth && layout.tileHeight === probeLayout.tileHeight ? resized : await (0, sharp.default)(resized).resize({
14355
- width: layout.tileWidth,
14356
- height: layout.tileHeight,
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
- }).jpeg({ quality: 82 }).toBuffer(),
14360
- top: layout.tiles[index]?.y ?? 0,
14361
- left: layout.tiles[index]?.x ?? 0
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 moving = filters?.includeStationary === true ? candidates : candidates.filter((candidate) => candidate.stationary !== true);
14602
- const droppedStationary = candidates.length - moving.length;
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({ rules: require_dist.array(NcViewerSummaryRuleSchema) }), { caller: "required" }),
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
- * `auth: 'admin'` in v1 (operator decision): a digest spans cameras and, once
22482
- * P2 lands, consumes the local vision model. A personal rule that fans vision
22483
- * calls across other people's cameras is a decision to take after the real
22484
- * cost has been watched for a few weeks, not before.
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 { rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
22922
- ...r,
22923
- readOnly: !c.isAdmin
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 = sum.store.get(input.ruleId);
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
- if (!c.isAdmin) throw new Error("forbidden: admin required to delete a summary rule");
22962
- await requireSummary().store.delete(input.ruleId);
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 positions = slim ? [] : data["positions"] ?? [];
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
- return (await stores.trackStore.queryHistorical({
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
- })).map((t) => ({
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 { cols, rows } = mosaicGrid(input.tileCount);
14220
- const maxWidth = input.maxWidth ?? 1920;
14221
- const tileWidth = Math.max(160, Math.floor(maxWidth / cols));
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
- for (let index = 0; index < Math.max(1, input.tileCount); index += 1) tiles.push({
14226
- index,
14227
- x: index % cols * tileWidth,
14228
- y: titleHeight + Math.floor(index / cols) * tileHeight,
14229
- w: tileWidth,
14230
- h: tileHeight
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: cols * tileWidth,
14234
- height: titleHeight + rows * tileHeight,
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 grid is recomputed from the survivors so
14301
- * the mosaic has no hole.
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 only if sharp cannot produce the final canvas at all; the caller
14304
- * treats that as "no mosaic" and still delivers the counts.
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 decoded = [];
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
- const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
14315
- const fitted = sharp(tile.jpeg).resize({
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 - decoded.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: Math.max(1, decoded.length),
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(decoded.map(async ({ resized }, index) => ({
14347
- input: layout.tileWidth === probeLayout.tileWidth && layout.tileHeight === probeLayout.tileHeight ? resized : await sharp(resized).resize({
14348
- width: layout.tileWidth,
14349
- height: layout.tileHeight,
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
- }).jpeg({ quality: 82 }).toBuffer(),
14353
- top: layout.tiles[index]?.y ?? 0,
14354
- left: layout.tiles[index]?.x ?? 0
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 moving = filters?.includeStationary === true ? candidates : candidates.filter((candidate) => candidate.stationary !== true);
14595
- const droppedStationary = candidates.length - moving.length;
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({ rules: array(NcViewerSummaryRuleSchema) }), { caller: "required" }),
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
- * `auth: 'admin'` in v1 (operator decision): a digest spans cameras and, once
22460
- * P2 lands, consumes the local vision model. A personal rule that fans vision
22461
- * calls across other people's cameras is a decision to take after the real
22462
- * cost has been watched for a few weeks, not before.
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 { rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
22900
- ...r,
22901
- readOnly: !c.isAdmin
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 = sum.store.get(input.ruleId);
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
- if (!c.isAdmin) throw new Error("forbidden: admin required to delete a summary rule");
22940
- await requireSummary().store.delete(input.ruleId);
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 positions = slim ? [] : data["positions"] ?? [];
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
- return (await stores.trackStore.queryHistorical({
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
- })).map((t) => ({
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-BdX09HCw.mjs")).catch((e) => {
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.96",
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",