@camstack/addon-post-analysis 1.2.621 → 1.2.623

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.
Files changed (15) hide show
  1. package/THIRD_PARTY_MODELS.md +2 -2
  2. package/dist/{clip-model-registry-BBzoIoKO.mjs → clip-model-registry-C58fyELd.mjs} +412 -66
  3. package/dist/{clip-model-registry-CcgzpELQ.js → clip-model-registry-KKT83z9U.js} +429 -65
  4. package/dist/embedding-encoder/index.js +1 -1
  5. package/dist/embedding-encoder/index.mjs +1 -1
  6. package/dist/pipeline-analytics/_stub.js +2 -2
  7. package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CkNZAMox.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-_ahOxwNA.mjs} +3 -3
  8. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-V5-s2hmZ.mjs +26 -0
  9. package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CssYIiFD.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B1HZu0i3.mjs} +1 -1
  10. package/dist/pipeline-analytics/{hostInit-wlnNaaYX.mjs → hostInit-CdRVx4vF.mjs} +3 -3
  11. package/dist/pipeline-analytics/index.js +888 -155
  12. package/dist/pipeline-analytics/index.mjs +888 -155
  13. package/dist/pipeline-analytics/remoteEntry.js +1 -1
  14. package/package.json +1 -1
  15. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BSMSev-b.mjs +0 -26
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_clip_model_registry = require("../clip-model-registry-CcgzpELQ.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-KKT83z9U.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_clip_model_registry.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -5402,40 +5402,118 @@ async function notificationLinkEndpoints(api, logger) {
5402
5402
  */
5403
5403
  var NC_TRACKS_AT_INSTANT_LIMIT = 25;
5404
5404
  /**
5405
- * Every source track with a position within `toleranceMs` of `timestamp`, its
5406
- * nearest box normalised, deduplicated by track id (a live copy and a
5407
- * persisted copy of one track are one track). Pure.
5405
+ * How far outside its own life a track may still be drawn: one detection tick.
5406
+ *
5407
+ * The box TOLERANCE (`toleranceMs`, 750 ms) is about how sparse a trail is —
5408
+ * a live track keeps a position every few frames — and it must never stretch
5409
+ * a LIFE. A track born after the frame was not on it: on 2026-10-22 the
5410
+ * tracker split a dog on camera 615 and the second piece, born 676 ms after
5411
+ * the key frame, was drawn at its first box as a second box on the dog. One
5412
+ * tick (~110–150 ms at that camera's detection rate, rounded up) absorbs the
5413
+ * gap between a frame and the detection that confirmed it, and nothing more.
5414
+ */
5415
+ var NC_TRACK_LIFE_SLACK_MS = 200;
5416
+ /**
5417
+ * A position this close to the instant is drawn as it is: one detection tick,
5418
+ * the same notion as {@link NC_TRACK_LIFE_SLACK_MS}. Anything farther is a
5419
+ * position of a MOVED subject, so it is either interpolated (a position on
5420
+ * each side inside the tolerance) or the track is left out — never drawn at a
5421
+ * later place than it was at the picture's instant.
5422
+ */
5423
+ var NC_BOX_EXACT_MS = NC_TRACK_LIFE_SLACK_MS;
5424
+ /** The box at `timestamp`: a near position as is, else the bracketing pair interpolated. */
5425
+ function boxAt(positions, timestamp, toleranceMs) {
5426
+ let nearest = null;
5427
+ let before = null;
5428
+ let after = null;
5429
+ for (const p of positions) {
5430
+ const d = p.timestamp - timestamp;
5431
+ if (nearest === null || Math.abs(d) < Math.abs(nearest.timestamp - timestamp)) nearest = p;
5432
+ if (d <= 0 && (before === null || p.timestamp > before.timestamp)) before = p;
5433
+ if (d >= 0 && (after === null || p.timestamp < after.timestamp)) after = p;
5434
+ }
5435
+ if (nearest === null) return null;
5436
+ const dt = nearest.timestamp - timestamp;
5437
+ if (Math.abs(dt) <= NC_BOX_EXACT_MS) return {
5438
+ bbox: nearest.bbox,
5439
+ score: nearest.score,
5440
+ dtMs: dt,
5441
+ interpolated: false
5442
+ };
5443
+ if (before === null || after === null || timestamp - before.timestamp > toleranceMs || after.timestamp - timestamp > toleranceMs) return null;
5444
+ const span = after.timestamp - before.timestamp;
5445
+ const f = span === 0 ? 0 : (timestamp - before.timestamp) / span;
5446
+ const lerp = (a, b) => a + (b - a) * f;
5447
+ return {
5448
+ bbox: {
5449
+ x: lerp(before.bbox.x, after.bbox.x),
5450
+ y: lerp(before.bbox.y, after.bbox.y),
5451
+ w: lerp(before.bbox.w, after.bbox.w),
5452
+ h: lerp(before.bbox.h, after.bbox.h)
5453
+ },
5454
+ score: nearest.score,
5455
+ dtMs: dt,
5456
+ interpolated: true
5457
+ };
5458
+ }
5459
+ /**
5460
+ * Every source track ALIVE at `timestamp` (± {@link NC_TRACK_LIFE_SLACK_MS})
5461
+ * with a box AT that instant: a trail position within {@link NC_BOX_EXACT_MS},
5462
+ * else the linear interpolation of the positions bracketing it (each within
5463
+ * `toleranceMs`). A track with neither is not drawn — `onDrop` names it.
5464
+ * Deduplicated by track id (a live copy and a persisted copy are one track).
5465
+ * Pure.
5408
5466
  */
5409
- function tracksAtInstant(sources, timestamp, toleranceMs, fallbackDims) {
5467
+ function tracksAtInstant(sources, timestamp, toleranceMs, fallbackDims, onDrop) {
5410
5468
  const out = /* @__PURE__ */ new Map();
5411
5469
  for (const track of sources) {
5412
5470
  if (out.has(track.trackId)) continue;
5413
- if (track.firstSeen > timestamp + toleranceMs || track.lastSeen < timestamp - toleranceMs) continue;
5471
+ if (track.firstSeen > timestamp + NC_TRACK_LIFE_SLACK_MS || track.lastSeen < timestamp - NC_TRACK_LIFE_SLACK_MS) {
5472
+ onDrop?.({
5473
+ trackId: track.trackId,
5474
+ className: track.className,
5475
+ reason: "outside-life"
5476
+ });
5477
+ continue;
5478
+ }
5414
5479
  const w = track.frameWidth ?? fallbackDims?.w ?? 0;
5415
5480
  const h = track.frameHeight ?? fallbackDims?.h ?? 0;
5416
- if (w <= 0 || h <= 0) continue;
5417
- let best = null;
5418
- let bestDelta = Number.POSITIVE_INFINITY;
5419
- for (const p of track.positions) {
5420
- const delta = Math.abs(p.timestamp - timestamp);
5421
- if (delta < bestDelta) {
5422
- bestDelta = delta;
5423
- best = p;
5424
- }
5481
+ if (w <= 0 || h <= 0) {
5482
+ onDrop?.({
5483
+ trackId: track.trackId,
5484
+ className: track.className,
5485
+ reason: "no-frame-dims"
5486
+ });
5487
+ continue;
5488
+ }
5489
+ const at = boxAt(track.positions, timestamp, toleranceMs);
5490
+ if (at === null) {
5491
+ const nearestDtMs = track.positions.reduce((best, p) => {
5492
+ const d = p.timestamp - timestamp;
5493
+ return best === void 0 || Math.abs(d) < Math.abs(best) ? d : best;
5494
+ }, void 0);
5495
+ onDrop?.({
5496
+ trackId: track.trackId,
5497
+ className: track.className,
5498
+ reason: "no-position-near-instant",
5499
+ ...nearestDtMs !== void 0 ? { nearestDtMs } : {}
5500
+ });
5501
+ continue;
5425
5502
  }
5426
- if (best === null || bestDelta > toleranceMs) continue;
5427
5503
  const label = track.subLabel ?? track.label;
5428
5504
  out.set(track.trackId, {
5429
5505
  trackId: track.trackId,
5430
5506
  className: track.className,
5431
5507
  ...label !== void 0 && label !== track.className ? { label } : {},
5432
- ...best.score !== void 0 ? { confidence: best.score } : {},
5508
+ ...at.score !== void 0 ? { confidence: at.score } : {},
5433
5509
  bbox: {
5434
- x: best.bbox.x / w,
5435
- y: best.bbox.y / h,
5436
- w: best.bbox.w / w,
5437
- h: best.bbox.h / h
5438
- }
5510
+ x: at.bbox.x / w,
5511
+ y: at.bbox.y / h,
5512
+ w: at.bbox.w / w,
5513
+ h: at.bbox.h / h
5514
+ },
5515
+ dtMs: at.dtMs,
5516
+ interpolated: at.interpolated
5439
5517
  });
5440
5518
  }
5441
5519
  return [...out.values()];
@@ -19337,11 +19415,16 @@ var DebugNoteArchiveStore = class {
19337
19415
  };
19338
19416
  //#endregion
19339
19417
  //#region src/pipeline-analytics/store/event-ledger-collection.ts
19418
+ /**
19419
+ * One event ledger (motion, audio, sensor, faces, plates …) over its
19420
+ * `ctx.records` kind. Every node has `ctx.records`, so there is no other path.
19421
+ */
19340
19422
  /** Matches {@link EventStore.LEDGER_PAGE_SIZE} — kept local to avoid a circular import. */
19341
19423
  var LEDGER_PAGE_SIZE = 500;
19342
19424
  var EventLedgerCollection = class EventLedgerCollection {
19343
19425
  collection;
19344
19426
  kind;
19427
+ records;
19345
19428
  autoIntegerId;
19346
19429
  legacyAtField;
19347
19430
  deviceIdMode;
@@ -19357,6 +19440,7 @@ var EventLedgerCollection = class EventLedgerCollection {
19357
19440
  this.logger = deps.logger;
19358
19441
  this.deriveBody = deps.deriveBody;
19359
19442
  this.indexFields = deps.definition.declaration.indexes.map((i) => i.fields);
19443
+ this.records = deps.records;
19360
19444
  const handle = deps.records.kind(deps.definition);
19361
19445
  this.kind = handle;
19362
19446
  }
@@ -19375,6 +19459,31 @@ var EventLedgerCollection = class EventLedgerCollection {
19375
19459
  }
19376
19460
  await this.kind.put(put);
19377
19461
  }
19462
+ /**
19463
+ * `upsertRow` as an op of {@link EventLedgerCollection.transact}. A row that
19464
+ * cannot be mapped THROWS here: inside a transaction a silently skipped write
19465
+ * would let the rest of the call commit without it.
19466
+ */
19467
+ async txUpsert(id, value) {
19468
+ const put = this.buildRecordPut(id, value);
19469
+ if (put === null) throw new require_clip_model_registry.InvalidRecordError(`${this.collection}/${id}: the record could not be mapped`);
19470
+ return this.kind.txPut(put);
19471
+ }
19472
+ /** `update` as an op of {@link EventLedgerCollection.transact}. */
19473
+ txUpdate(id, patch) {
19474
+ return this.kind.txPatch(id, this.derived(patch, "patch"));
19475
+ }
19476
+ /** `deleteKey` as an op of {@link EventLedgerCollection.transact}. */
19477
+ txDelete(id) {
19478
+ return this.kind.txDelete(id);
19479
+ }
19480
+ /**
19481
+ * Run ops (built by the `tx*` methods of ledgers whose kinds share a
19482
+ * `transactFamily`) in ONE transaction: all land or none does.
19483
+ */
19484
+ transact(ops) {
19485
+ return this.records.transact(ops);
19486
+ }
19378
19487
  async insert(record) {
19379
19488
  const idNum = this.autoIntegerId ? await this.kind.allocateId() : null;
19380
19489
  const id = idNum !== null ? String(idNum) : typeof record["id"] === "string" ? record["id"] : null;
@@ -20451,8 +20560,8 @@ var FACE_REF_COLUMNS = ["deviceId"];
20451
20560
  * D312) a predicate that silently matched nothing. The faces ledger therefore derives a
20452
20561
  * boolean `recognized` from every write that touches `recognizedIdentityId`
20453
20562
  * (`face-recognized.ts`), and the views are plain ranges over the `recognized_ts` /
20454
- * `assigned_recognized_ts` indexes. A row written before the field existed lacks it until
20455
- * `scripts/hub/backfill-faces-recognized.ts` has run.
20563
+ * `assigned_recognized_ts` indexes. Rows written before the field existed were
20564
+ * backfilled offline on the operator hubs on 2026-10-22 (the one-shot script is deleted).
20456
20565
  *
20457
20566
  * `assigned` is `BOOLEAN NOT NULL`; the backend serialises `false` to `0` at
20458
20567
  * the bind (`serializeColumnValue`) and decodes `0` back to `false` on read, so
@@ -22316,8 +22425,9 @@ var RetrainAnnotationStore = class {
22316
22425
  * The old rows' deletion, the new rows, the frame's `dismissed` set and its
22317
22426
  * bumped `revision` run under a per-frame commit lock, guarded by
22318
22427
  * `commit.fromRevision`: a concurrent save in this process leaves one winner
22319
- * and one `conflict` — never a blend. The steps are separate records writes,
22320
- * so a crash between them is NOT rolled back.
22428
+ * and one `conflict` — never a blend. The deletion, the new rows and the frame
22429
+ * row are ONE records `transact` (the four retrain kinds share a family): a
22430
+ * failure leaves the frame exactly as it was.
22321
22431
  *
22322
22432
  * Returns the persisted rows so the caller renders ids it can address.
22323
22433
  */
@@ -22434,21 +22544,14 @@ var RetrainAnnotationStore = class {
22434
22544
  }
22435
22545
  return stamped;
22436
22546
  }
22437
- /** Remove every annotation of a frame — the operator un-selecting it. */
22438
- async deleteForFrame(mediaKey) {
22439
- const rows = await this.listByFrame(mediaKey);
22440
- for (const row of rows) try {
22441
- await this.annotations.deleteKey(row.id);
22442
- } catch (err) {
22443
- this.logger.warn("retrain annotation delete failed", {
22444
- tags: { deviceId: row.deviceId },
22445
- meta: {
22446
- annotationId: row.id,
22447
- error: String(err)
22448
- }
22449
- });
22450
- }
22451
- return rows.length;
22547
+ /**
22548
+ * The delete ops of every annotation of a frame, for ONE transaction with
22549
+ * the frame row and its memberships (RetrainService.removeFrame). A failed
22550
+ * read THROWS: "no annotations" must never be the answer to an unreadable
22551
+ * store, because the frame row would then be deleted over live boxes.
22552
+ */
22553
+ async deleteOpsForFrame(mediaKey) {
22554
+ return (await this.annotations.query({ where: { mediaKey } })).map((row) => this.annotations.txDelete(row.id));
22452
22555
  }
22453
22556
  async replaceForFrameOnRecords(mediaKey, rows, commit, nextRevision) {
22454
22557
  const frame = await this.frames.get(mediaKey);
@@ -22461,12 +22564,14 @@ var RetrainAnnotationStore = class {
22461
22564
  kind: "conflict",
22462
22565
  currentRevision
22463
22566
  };
22464
- await this.annotations.deleteWhere({ where: { mediaKey } });
22465
- for (const row of rows) await this.annotations.upsertRow(row.id, this.toRecord(row));
22466
- await this.frames.update(mediaKey, {
22567
+ const ops = [];
22568
+ for (const existing of await this.annotations.query({ where: { mediaKey } })) ops.push(this.annotations.txDelete(existing.id));
22569
+ for (const row of rows) ops.push(await this.annotations.txUpsert(row.id, this.toRecord(row)));
22570
+ ops.push(await this.frames.txUpdate(mediaKey, {
22467
22571
  revision: nextRevision,
22468
22572
  ...commit.dismissed !== void 0 ? { dismissed: [...commit.dismissed] } : {}
22469
- });
22573
+ }));
22574
+ await this.frames.transact(ops);
22470
22575
  return {
22471
22576
  kind: "saved",
22472
22577
  rows,
@@ -22484,8 +22589,10 @@ var RetrainAnnotationStore = class {
22484
22589
  kind: "conflict",
22485
22590
  currentRevision
22486
22591
  };
22487
- for (const row of eligible) await this.annotations.update(row.id, { origin: origins.get(row.id) ?? null });
22488
- await this.frames.update(mediaKey, { revision: nextRevision });
22592
+ const ops = [];
22593
+ for (const row of eligible) ops.push(await this.annotations.txUpdate(row.id, { origin: origins.get(row.id) ?? null }));
22594
+ ops.push(await this.frames.txUpdate(mediaKey, { revision: nextRevision }));
22595
+ await this.frames.transact(ops);
22489
22596
  return {
22490
22597
  kind: "updated",
22491
22598
  updated: eligible.length,
@@ -22656,16 +22763,16 @@ var RetrainCollectionStore = class {
22656
22763
  }, counts.get(input.id) ?? 0)
22657
22764
  };
22658
22765
  }
22659
- /** Remove the collection and its memberships — members first, so a crash
22660
- * never leaves memberships pointing at a name that no longer exists. */
22766
+ /** Remove the collection and its memberships in ONE transaction: a failure
22767
+ * leaves the collection with all its members, never memberships pointing at
22768
+ * a name that no longer exists, nor a collection that silently lost some. */
22661
22769
  async delete(id) {
22662
22770
  if (await this.readRow(id) === null) return {
22663
22771
  deleted: false,
22664
22772
  memberFrameIds: []
22665
22773
  };
22666
22774
  const members = await this.memberships.query({ where: { collectionId: id } });
22667
- for (const m of members) await this.memberships.deleteKey(m.id);
22668
- await this.collections.deleteKey(id);
22775
+ await this.collections.transact([...members.map((m) => this.memberships.txDelete(m.id)), this.collections.txDelete(id)]);
22669
22776
  this.logger.info("retrain collection deleted", { meta: {
22670
22777
  collectionId: id,
22671
22778
  members: members.length
@@ -22694,11 +22801,29 @@ var RetrainCollectionStore = class {
22694
22801
  await this.memberships.deleteKey(key);
22695
22802
  return true;
22696
22803
  }
22697
- /** Remove a frame from EVERY collection — the frame itself is leaving. */
22698
- async forgetFrame(frameId) {
22699
- const rows = await this.membershipRows({ frameId });
22700
- for (const r of rows) await this.memberships.deleteKey(r.id);
22701
- return rows.length;
22804
+ /**
22805
+ * The delete ops of every membership of a frame, for ONE transaction with
22806
+ * the frame row and its annotations (RetrainService.removeFrame). A failed
22807
+ * read THROWS: the frame row must not be deleted over memberships nobody
22808
+ * could list.
22809
+ */
22810
+ async forgetFrameOps(frameId) {
22811
+ return (await this.memberships.query({ where: { frameId } })).map((r) => this.memberships.txDelete(r.id));
22812
+ }
22813
+ /**
22814
+ * The op that adds a frame to a collection, for ONE transaction with the
22815
+ * frame's own row (a still stored from the recording). `null` when it is
22816
+ * already a member: there is nothing to write.
22817
+ */
22818
+ async addFrameOp(input) {
22819
+ const key = membershipKey(input.collectionId, input.frameId);
22820
+ if (await this.memberships.get(key) !== null) return null;
22821
+ return this.memberships.txUpsert(key, {
22822
+ collectionId: input.collectionId,
22823
+ frameId: input.frameId,
22824
+ deviceId: input.deviceId,
22825
+ addedAt: this.now()
22826
+ });
22702
22827
  }
22703
22828
  /** The frames of a collection, in the order they were added. */
22704
22829
  async listFrameIds(collectionId) {
@@ -23290,7 +23415,7 @@ var RetrainFrameStore = class {
23290
23415
  * returned untouched; the blob lands before the row; a refusal is named.
23291
23416
  * `capturedAt` IS the instant — the camera saw this frame then.
23292
23417
  */
23293
- async storeRecordingStill(input) {
23418
+ async storeRecordingStill(input, withRow = []) {
23294
23419
  const frameId = recordingFrameId(input.deviceId, input.epochMs);
23295
23420
  const existing = await this.get(frameId);
23296
23421
  if (existing !== null) return {
@@ -23337,7 +23462,7 @@ var RetrainFrameStore = class {
23337
23462
  relativePath: path,
23338
23463
  data: input.bytes
23339
23464
  });
23340
- await this.ledger.upsertRow(frameId, {
23465
+ const row = await this.ledger.txUpsert(frameId, {
23341
23466
  deviceId: frame.deviceId,
23342
23467
  trackId: "",
23343
23468
  sourceMediaId: 0,
@@ -23354,6 +23479,7 @@ var RetrainFrameStore = class {
23354
23479
  origin: "recording",
23355
23480
  epochMs: input.epochMs
23356
23481
  });
23482
+ await this.ledger.transact([row, ...withRow]);
23357
23483
  };
23358
23484
  try {
23359
23485
  if (this.writeGate) await this.writeGate.run(persist);
@@ -23407,22 +23533,33 @@ var RetrainFrameStore = class {
23407
23533
  /**
23408
23534
  * Drop a copy and its blob — the operator un-selecting a frame.
23409
23535
  *
23410
- * The caller must have removed the frame's annotations first; this store does
23411
- * not cascade, because a store that deletes rows in another table on a
23412
- * best-effort write is how a half-deleted annotation set happens.
23536
+ * The ROW goes first, in one transaction with `withRow` (the frame's
23537
+ * annotations and collection memberships, which the caller builds): a failure
23538
+ * leaves the frame, its boxes and its memberships all in place, and throws.
23539
+ * The blob goes after: if that fails the row is already gone and the bytes
23540
+ * are reclaimable junk, logged, never a row pointing at missing media.
23413
23541
  */
23414
- async deleteFrame(frame) {
23542
+ async deleteFrame(frame, withRow = []) {
23415
23543
  const remove = async () => {
23544
+ try {
23545
+ await this.ledger.transact([...withRow, this.ledger.txDelete(frame.frameId)]);
23546
+ } catch (err) {
23547
+ this.logger.error("retrain frame delete failed; nothing was removed", {
23548
+ tags: { deviceId: frame.deviceId },
23549
+ meta: {
23550
+ frameId: frame.frameId,
23551
+ error: String(err)
23552
+ }
23553
+ });
23554
+ throw err;
23555
+ }
23416
23556
  try {
23417
23557
  await this.storage.delete({
23418
23558
  location: frame.locationId ?? LEGACY_RETRAIN_MEDIA_LOCATION,
23419
23559
  relativePath: framePath(frame)
23420
23560
  });
23421
- } catch {}
23422
- try {
23423
- await this.ledger.deleteKey(frame.frameId);
23424
23561
  } catch (err) {
23425
- this.logger.warn("RetrainFrameStore.deleteFrame row delete failed", {
23562
+ this.logger.warn("retrain frame removed but its blob could not be deleted", {
23426
23563
  tags: { deviceId: frame.deviceId },
23427
23564
  meta: {
23428
23565
  frameId: frame.frameId,
@@ -28418,7 +28555,9 @@ function incomingFromSystemEvent(event, lookup) {
28418
28555
  const blindForMs = numberField(data, "blindForMs");
28419
28556
  if (deviceId === void 0 || blindForMs === void 0) return null;
28420
28557
  const identity = lookup(deviceId);
28421
- const minutes = Math.max(1, Math.round(blindForMs / 6e4));
28558
+ const motionMs = numberField(data, "motionMs") ?? blindForMs;
28559
+ const sessions = numberField(data, "sessions") ?? 1;
28560
+ const minutes = Math.max(1, Math.round(motionMs / 6e4));
28422
28561
  return systemSubject({
28423
28562
  facts,
28424
28563
  kind: "detection-blind",
@@ -28427,13 +28566,39 @@ function incomingFromSystemEvent(event, lookup) {
28427
28566
  bodyKey: "system.detection-blind.body",
28428
28567
  textParams: {
28429
28568
  ...deviceTextParams(deviceId, identity),
28430
- minutes: String(minutes)
28569
+ minutes: String(minutes),
28570
+ sessions: String(sessions)
28431
28571
  },
28432
28572
  textCount: minutes,
28433
28573
  deviceId,
28434
28574
  ...identity !== void 0 ? { deviceType: identity.type } : {}
28435
28575
  });
28436
28576
  }
28577
+ case "pipeline.motion-noisy":
28578
+ case "pipeline.motion-calm": {
28579
+ const deviceId = numberField(data, "deviceId");
28580
+ const dutyPct = numberField(data, "dutyPct");
28581
+ const sessions = numberField(data, "sessions");
28582
+ const windowMs = numberField(data, "windowMs");
28583
+ if (deviceId === void 0 || dutyPct === void 0 || sessions === void 0 || windowMs === void 0) return null;
28584
+ const noisy = event.category === "pipeline.motion-noisy";
28585
+ const identity = lookup(deviceId);
28586
+ return systemSubject({
28587
+ facts,
28588
+ kind: noisy ? "motion-noisy" : "motion-calm",
28589
+ subject: `motion-noise:${deviceId}`,
28590
+ titleKey: noisy ? "system.motion-noisy.title" : "system.motion-calm.title",
28591
+ bodyKey: noisy ? "system.motion-noisy.body" : "system.motion-calm.body",
28592
+ textParams: {
28593
+ ...deviceTextParams(deviceId, identity),
28594
+ minutes: String(Math.max(1, Math.round(windowMs / 6e4))),
28595
+ duty: String(dutyPct),
28596
+ sessions: String(sessions)
28597
+ },
28598
+ deviceId,
28599
+ ...identity !== void 0 ? { deviceType: identity.type } : {}
28600
+ });
28601
+ }
28437
28602
  case "recording.health.stalled": {
28438
28603
  const deviceId = numberField(data, "deviceId");
28439
28604
  const idleMs = numberField(data, "idleMs");
@@ -28515,6 +28680,33 @@ function incomingFromSystemEvent(event, lookup) {
28515
28680
  ...nodeId !== void 0 ? { nodeId } : {}
28516
28681
  });
28517
28682
  }
28683
+ case "notification.rule-broken":
28684
+ case "notification.rule-repaired": {
28685
+ const ruleId = stringField(data, "ruleId");
28686
+ if (ruleId === void 0) return null;
28687
+ const broken = event.category === "notification.rule-broken";
28688
+ const kind = broken ? "rule-broken" : "rule-repaired";
28689
+ const ruleName = stringField(data, "ruleName") ?? ruleId;
28690
+ const reason = stringField(data, "reason") ?? "unknown";
28691
+ const ids = data["offendingIds"];
28692
+ const offendingIds = Array.isArray(ids) ? ids.filter((id) => typeof id === "string").join(", ") : "";
28693
+ return systemSubject({
28694
+ facts,
28695
+ kind,
28696
+ subject: `rule-health:${ruleId}`,
28697
+ titleKey: `system.${kind}.title`,
28698
+ bodyKey: broken ? `system.rule-broken.body.${reason}` : "system.rule-repaired.body",
28699
+ textParams: broken ? {
28700
+ ruleId,
28701
+ ruleName,
28702
+ brokenReason: reason,
28703
+ offendingIds
28704
+ } : {
28705
+ ruleId,
28706
+ ruleName
28707
+ }
28708
+ });
28709
+ }
28518
28710
  case "update.available": return incomingFromUpdateAvailable(facts, data);
28519
28711
  case "addon.updated": {
28520
28712
  const update = parseAddonUpdateComplete(data);
@@ -28524,7 +28716,7 @@ function incomingFromSystemEvent(event, lookup) {
28524
28716
  case "system.restarting": return incomingFromNodeRestarting(facts, data);
28525
28717
  case "recording.export.completed": return incomingFromExportCompleted(facts, data);
28526
28718
  case "backup.run-completed": return incomingFromBackupCompleted(facts, data);
28527
- case "alert.episode": return incomingFromAlertEpisode(facts);
28719
+ case "alert.episode": return incomingFromAlertEpisode(facts, lookup);
28528
28720
  default: return null;
28529
28721
  }
28530
28722
  }
@@ -28540,9 +28732,11 @@ function incomingFromSystemEvent(event, lookup) {
28540
28732
  * Whether an edge is notified at all (once per episode, reconciled from the
28541
28733
  * alerts store) is decided before this point, by `NcAlertEpisodeLedger`.
28542
28734
  */
28543
- function incomingFromAlertEpisode(facts) {
28735
+ function incomingFromAlertEpisode(facts, lookup) {
28544
28736
  const payload = require_clip_model_registry.parseAlertEpisodePayload(facts.data);
28545
28737
  if (payload === null) return null;
28738
+ const attention = incomingFromDeviceAttention(facts, payload, lookup);
28739
+ if (attention !== null) return attention;
28546
28740
  const kind = require_clip_model_registry.alertEpisodeKind(payload.severity, payload.phase);
28547
28741
  if (kind === null) return null;
28548
28742
  return systemSubject({
@@ -28563,6 +28757,38 @@ function incomingFromAlertEpisode(facts) {
28563
28757
  ...payload.nodeId !== void 0 ? { nodeId: payload.nodeId } : {}
28564
28758
  });
28565
28759
  }
28760
+ /**
28761
+ * A CRITICAL alert whose source is a DEVICE (D911) is announced in the devices
28762
+ * family — `device-attention-required` / `-resolved`, carrying the device id so
28763
+ * a rule's device filters apply — and NOT as `alert-critical`, so one alert
28764
+ * never notifies twice through two families. A device source whose id is not a
28765
+ * device id falls through to the generic alert kinds.
28766
+ */
28767
+ function incomingFromDeviceAttention(facts, payload, lookup) {
28768
+ if (payload.sourceType !== "device" || payload.sourceId === void 0) return null;
28769
+ const kind = require_clip_model_registry.deviceAttentionKind(payload.severity, payload.phase);
28770
+ if (kind === null) return null;
28771
+ const deviceId = Number(payload.sourceId);
28772
+ if (!Number.isInteger(deviceId)) return null;
28773
+ const identity = lookup?.(deviceId);
28774
+ return systemSubject({
28775
+ facts,
28776
+ kind,
28777
+ subject: `alert:${payload.episodeId}`,
28778
+ titleKey: `system.${kind}.title`,
28779
+ bodyKey: `system.${kind}.body`,
28780
+ textParams: {
28781
+ ...deviceTextParams(deviceId, identity),
28782
+ title: payload.title,
28783
+ message: payload.message,
28784
+ category: payload.category,
28785
+ actionUrl: payload.actionUrl ?? ""
28786
+ },
28787
+ deviceId,
28788
+ ...identity !== void 0 ? { deviceType: identity.type } : {},
28789
+ ...payload.nodeId !== void 0 ? { nodeId: payload.nodeId } : {}
28790
+ });
28791
+ }
28566
28792
  /** The shells whose update has its own sentence. Anything else gets the plain
28567
28793
  * one: a body that names a shell nobody observed is worse than a vague body. */
28568
28794
  var WRAPPER_BODY_KINDS = new Set([
@@ -30520,8 +30746,8 @@ var en_default = {
30520
30746
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
30521
30747
  "occupancy.op.occupied": "occupied",
30522
30748
  "occupancy.op.free": "free",
30523
- "system.device-online.title": "Online — {{deviceName}}",
30524
- "system.device-online.body": "Device {{deviceName}} is online",
30749
+ "system.device-online.title": "Back online — {{deviceName}}",
30750
+ "system.device-online.body": "Device {{deviceName}} is back online",
30525
30751
  "system.device-offline.title": "Offline — {{deviceName}}",
30526
30752
  "system.device-offline.body": "Device {{deviceName}} is offline",
30527
30753
  "system.device-disabled.title": "Switched off — {{deviceName}}",
@@ -30534,14 +30760,14 @@ var en_default = {
30534
30760
  "system.device-battery-normal.title": "Battery back to normal — {{deviceName}}",
30535
30761
  "system.device-battery-normal.body": "{{deviceName}} is back to {{percentage}}% battery",
30536
30762
  "system.device-battery-normal.body.binary": "{{deviceName}} no longer reports a low battery",
30537
- "system.stream-online.title": "Stream online — {{deviceName}}",
30538
- "system.stream-online.body": "Stream for camera {{deviceName}} is online",
30539
- "system.stream-online.body.profiled": "{{profile}} stream for camera {{deviceName}} is online",
30763
+ "system.stream-online.title": "Stream back online — {{deviceName}}",
30764
+ "system.stream-online.body": "Stream for camera {{deviceName}} is back online",
30765
+ "system.stream-online.body.profiled": "{{profile}} stream for camera {{deviceName}} is back online",
30540
30766
  "system.stream-offline.title": "Stream offline — {{deviceName}}",
30541
30767
  "system.stream-offline.body": "Stream for camera {{deviceName}} is offline",
30542
30768
  "system.stream-offline.body.profiled": "{{profile}} stream for camera {{deviceName}} is offline",
30543
- "system.node-online.title": "Node online — {{nodeName}}",
30544
- "system.node-online.body.agent": "Agent {{nodeName}} is online",
30769
+ "system.node-online.title": "Node back online — {{nodeName}}",
30770
+ "system.node-online.body.agent": "Agent {{nodeName}} is back online",
30545
30771
  "system.node-online.body.agent.after": "Agent {{nodeName}} is back online after {{offlineFor}} offline",
30546
30772
  "system.node-offline.title": "Node offline — {{nodeName}}",
30547
30773
  "system.node-offline.body.agent": "Agent {{nodeName}} is offline",
@@ -30553,18 +30779,26 @@ var en_default = {
30553
30779
  "system.addon-crash-loop.body": "The {{addons}} addon on node {{nodeId}} crashed {{count}} times in a row and will NOT be restarted. Everything it provides is offline until it is repaired.",
30554
30780
  "system.detection-blind.title": "Detecting nothing — {{deviceName}}",
30555
30781
  "system.detection-blind.body": {
30556
- "one": "Camera {{deviceName}} has been analysed for {{minutes}} minute with no detection at all",
30557
- "other": "Camera {{deviceName}} has been analysed for {{minutes}} minutes with no detection at all"
30782
+ "one": "Camera {{deviceName}} moved for {{minutes}} minute over {{sessions}} sessions and the detector produced nothing at all, not even a weak candidate",
30783
+ "other": "Camera {{deviceName}} moved for {{minutes}} minutes over {{sessions}} sessions and the detector produced nothing at all, not even a weak candidate"
30558
30784
  },
30785
+ "system.motion-noisy.title": "Constant motion, no subject — {{deviceName}}",
30786
+ "system.motion-noisy.body": "Camera {{deviceName}} has been in motion {{duty}}% of the last {{minutes}} minutes ({{sessions}} sessions) without a single detection. The detector is fine — tune this camera's motion zones or sensitivity.",
30787
+ "system.motion-calm.title": "Motion calm again — {{deviceName}}",
30788
+ "system.motion-calm.body": "Camera {{deviceName}} is no longer in constant motion ({{duty}}% of the last {{minutes}} minutes, {{sessions}} sessions)",
30559
30789
  "system.recording-stalled.title": "Recording stalled — {{deviceName}}",
30560
30790
  "system.recording-stalled.body": {
30561
30791
  "one": "Camera {{deviceName}} has not written a recording segment for {{minutes}} minute",
30562
30792
  "other": "Camera {{deviceName}} has not written a recording segment for {{minutes}} minutes"
30563
30793
  },
30564
- "system.recording-healthy.title": "Recording healthy — {{deviceName}}",
30794
+ "system.recording-healthy.title": "Recording again — {{deviceName}}",
30565
30795
  "system.recording-healthy.body": "Camera {{deviceName}} is writing recording segments again",
30566
30796
  "system.recording-degraded.title": "Recording degraded — {{deviceName}}",
30567
30797
  "system.recording-degraded.body": "Camera {{deviceName}} exhausted its writer restart budget ({{message}})",
30798
+ "system.device-attention-required.title": "{{title}}",
30799
+ "system.device-attention-required.body": "{{message}}",
30800
+ "system.device-attention-resolved.title": "Resolved: {{title}}",
30801
+ "system.device-attention-resolved.body": "{{deviceName}} no longer needs attention",
30568
30802
  "system.storage-location-low.title": "Storage low — {{locationName}}",
30569
30803
  "system.storage-location-low.body": "Location {{locationName}} is down to {{freePercent}}% free ({{reason}})",
30570
30804
  "system.storage-location-critical.title": "Storage critical — {{locationName}}",
@@ -30822,7 +31056,13 @@ var en_default = {
30822
31056
  "calendar-events.line.timed": "• {{when}} {{title}}{{where}}",
30823
31057
  "calendar-events.line.allDay": "• All day {{title}}{{where}}",
30824
31058
  "calendar-events.where": " @ {{location}}",
30825
- "calendar-events.more": "…and {{count}} more"
31059
+ "calendar-events.more": "…and {{count}} more",
31060
+ "system.rule-broken.title": "Notification rule broken — {{ruleName}}",
31061
+ "system.rule-broken.body.unparseable": "The rule \"{{ruleName}}\" cannot be loaded and sends nothing. Open it and save it again.",
31062
+ "system.rule-broken.body.device-deleted": "The rule \"{{ruleName}}\" still names a deleted device ({{offendingIds}}) and was switched off. Pick the device again.",
31063
+ "system.rule-broken.body.target-deleted": "The rule \"{{ruleName}}\" still sends to a deleted destination ({{offendingIds}}) and was switched off. Pick a destination again.",
31064
+ "system.rule-repaired.title": "Notification rule works again — {{ruleName}}",
31065
+ "system.rule-repaired.body": "The rule \"{{ruleName}}\" loads and names nothing deleted any more."
30826
31066
  };
30827
31067
  //#endregion
30828
31068
  //#region src/notification-center/texts/it.json
@@ -30907,8 +31147,8 @@ var it_default = {
30907
31147
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
30908
31148
  "occupancy.op.occupied": "occupata",
30909
31149
  "occupancy.op.free": "libera",
30910
- "system.device-online.title": "Online — {{deviceName}}",
30911
- "system.device-online.body": "Il dispositivo {{deviceName}} è online",
31150
+ "system.device-online.title": "Di nuovo online — {{deviceName}}",
31151
+ "system.device-online.body": "Il dispositivo {{deviceName}} è di nuovo online",
30912
31152
  "system.device-offline.title": "Offline — {{deviceName}}",
30913
31153
  "system.device-offline.body": "Il dispositivo {{deviceName}} è offline",
30914
31154
  "system.device-disabled.title": "Spento — {{deviceName}}",
@@ -30921,14 +31161,14 @@ var it_default = {
30921
31161
  "system.device-battery-normal.title": "Batteria rientrata — {{deviceName}}",
30922
31162
  "system.device-battery-normal.body": "{{deviceName}} è risalito al {{percentage}}% di batteria",
30923
31163
  "system.device-battery-normal.body.binary": "{{deviceName}} non segnala più la batteria scarica",
30924
- "system.stream-online.title": "Stream online — {{deviceName}}",
30925
- "system.stream-online.body": "Lo stream della camera {{deviceName}} è online",
30926
- "system.stream-online.body.profiled": "Lo stream {{profile}} della camera {{deviceName}} è online",
31164
+ "system.stream-online.title": "Stream di nuovo online — {{deviceName}}",
31165
+ "system.stream-online.body": "Lo stream della camera {{deviceName}} è di nuovo online",
31166
+ "system.stream-online.body.profiled": "Lo stream {{profile}} della camera {{deviceName}} è di nuovo online",
30927
31167
  "system.stream-offline.title": "Stream offline — {{deviceName}}",
30928
31168
  "system.stream-offline.body": "Lo stream della camera {{deviceName}} è offline",
30929
31169
  "system.stream-offline.body.profiled": "Lo stream {{profile}} della camera {{deviceName}} è offline",
30930
- "system.node-online.title": "Nodo online — {{nodeName}}",
30931
- "system.node-online.body.agent": "L'agent {{nodeName}} è online",
31170
+ "system.node-online.title": "Nodo di nuovo online — {{nodeName}}",
31171
+ "system.node-online.body.agent": "L'agent {{nodeName}} è di nuovo online",
30932
31172
  "system.node-online.body.agent.after": "L'agent {{nodeName}} è di nuovo online dopo {{offlineFor}} offline",
30933
31173
  "system.node-offline.title": "Nodo offline — {{nodeName}}",
30934
31174
  "system.node-offline.body.agent": "L'agent {{nodeName}} è offline",
@@ -30943,10 +31183,14 @@ var it_default = {
30943
31183
  "one": "La camera {{deviceName}} non scrive un segmento da {{minutes}} minuto",
30944
31184
  "other": "La camera {{deviceName}} non scrive un segmento da {{minutes}} minuti"
30945
31185
  },
30946
- "system.recording-healthy.title": "Registrazione ok — {{deviceName}}",
31186
+ "system.recording-healthy.title": "Registra di nuovo — {{deviceName}}",
30947
31187
  "system.recording-healthy.body": "La camera {{deviceName}} scrive di nuovo i segmenti",
30948
31188
  "system.recording-degraded.title": "Registrazione degradata — {{deviceName}}",
30949
31189
  "system.recording-degraded.body": "La camera {{deviceName}} ha esaurito i riavvii del writer ({{message}})",
31190
+ "system.device-attention-required.title": "{{title}}",
31191
+ "system.device-attention-required.body": "{{message}}",
31192
+ "system.device-attention-resolved.title": "Risolto: {{title}}",
31193
+ "system.device-attention-resolved.body": "{{deviceName}} non richiede più intervento",
30950
31194
  "system.storage-location-low.title": "Storage basso — {{locationName}}",
30951
31195
  "system.storage-location-low.body": "La location {{locationName}} è al {{freePercent}}% libero ({{reason}})",
30952
31196
  "system.storage-location-critical.title": "Storage critico — {{locationName}}",
@@ -30955,9 +31199,13 @@ var it_default = {
30955
31199
  "system.storage-location-ok.body": "La location {{locationName}} ha recuperato spazio libero",
30956
31200
  "system.detection-blind.title": "Senza detection — {{deviceName}}",
30957
31201
  "system.detection-blind.body": {
30958
- "one": "La camera {{deviceName}} è analizzata da {{minutes}} minuto senza alcun rilevamento",
30959
- "other": "La camera {{deviceName}} è analizzata da {{minutes}} minuti senza alcun rilevamento"
31202
+ "one": "La camera {{deviceName}} ha avuto movimento per {{minutes}} minuto in {{sessions}} sessioni e il detector non ha prodotto nulla, nemmeno un candidato debole",
31203
+ "other": "La camera {{deviceName}} ha avuto movimento per {{minutes}} minuti in {{sessions}} sessioni e il detector non ha prodotto nulla, nemmeno un candidato debole"
30960
31204
  },
31205
+ "system.motion-noisy.title": "Movimento continuo senza soggetto — {{deviceName}}",
31206
+ "system.motion-noisy.body": "La camera {{deviceName}} è stata in movimento il {{duty}}% degli ultimi {{minutes}} minuti ({{sessions}} sessioni) senza alcun rilevamento. Il detector funziona — regola le zone di movimento o la sensibilità di questa camera.",
31207
+ "system.motion-calm.title": "Movimento di nuovo calmo — {{deviceName}}",
31208
+ "system.motion-calm.body": "La camera {{deviceName}} non è più in movimento continuo ({{duty}}% degli ultimi {{minutes}} minuti, {{sessions}} sessioni)",
30961
31209
  "system.addon-update-available.title": {
30962
31210
  "one": "{{count}} aggiornamento addon disponibile",
30963
31211
  "other": "{{count}} aggiornamenti addon disponibili"
@@ -31209,7 +31457,13 @@ var it_default = {
31209
31457
  "calendar-events.line.timed": "• {{when}} {{title}}{{where}}",
31210
31458
  "calendar-events.line.allDay": "• Tutto il giorno {{title}}{{where}}",
31211
31459
  "calendar-events.where": " @ {{location}}",
31212
- "calendar-events.more": "…e altri {{count}}"
31460
+ "calendar-events.more": "…e altri {{count}}",
31461
+ "system.rule-broken.title": "Regola di notifica rotta — {{ruleName}}",
31462
+ "system.rule-broken.body.unparseable": "La regola \"{{ruleName}}\" non si carica e non invia nulla. Aprila e salvala di nuovo.",
31463
+ "system.rule-broken.body.device-deleted": "La regola \"{{ruleName}}\" usa ancora un dispositivo eliminato ({{offendingIds}}) ed è stata disattivata. Scegli di nuovo il dispositivo.",
31464
+ "system.rule-broken.body.target-deleted": "La regola \"{{ruleName}}\" invia ancora a una destinazione eliminata ({{offendingIds}}) ed è stata disattivata. Scegli di nuovo una destinazione.",
31465
+ "system.rule-repaired.title": "Regola di notifica di nuovo funzionante — {{ruleName}}",
31466
+ "system.rule-repaired.body": "La regola \"{{ruleName}}\" si carica e non usa più nulla di eliminato."
31213
31467
  };
31214
31468
  //#endregion
31215
31469
  //#region src/notification-center/text-catalog.ts
@@ -33480,6 +33734,10 @@ function systemEventKeys(kind) {
33480
33734
  titleKey: `system.${kind}.title`,
33481
33735
  bodyKey: `system.${kind}.body.cleanWater`
33482
33736
  };
33737
+ if (kind === "rule-broken") return {
33738
+ titleKey: `system.${kind}.title`,
33739
+ bodyKey: `system.${kind}.body.device-deleted`
33740
+ };
33483
33741
  return {
33484
33742
  titleKey: `system.${kind}.title`,
33485
33743
  bodyKey: `system.${kind}.body`
@@ -33507,6 +33765,7 @@ function systemEventSubject(kind, scope) {
33507
33765
  if (kind === "node-online" || kind === "node-offline") return `node:${scope.nodeId ?? "hub"}`;
33508
33766
  if (kind === "node-inference-unavailable") return `node-inference:${scope.nodeId ?? "hub"}`;
33509
33767
  if (kind === "detection-blind") return `detection-blind:${scope.deviceId ?? 0}`;
33768
+ if (kind === "motion-noisy" || kind === "motion-calm") return `motion-noise:${scope.deviceId ?? 0}`;
33510
33769
  if (kind === "addon-crash-loop") return `addon-crash:${scope.nodeId ?? "hub"}:${TEST_CRASHED_RUNNER}`;
33511
33770
  if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
33512
33771
  if (kind === "wrapper-update-available") return `wrapper:${scope.nodeId ?? "hub"}`;
@@ -33652,6 +33911,8 @@ function planRuleTest(input) {
33652
33911
  seconds: "30",
33653
33912
  devices: "cuda:0",
33654
33913
  minutes: "15",
33914
+ duty: "55",
33915
+ sessions: "96",
33655
33916
  addons: TEST_CRASHED_RUNNER,
33656
33917
  list: "@camstack/addon-pipeline 1.0.1 (current 1.0.0)",
33657
33918
  nodes: "hub",
@@ -39735,6 +39996,99 @@ var NcRecordedCutMemo = class {
39735
39996
  }
39736
39997
  };
39737
39998
  //#endregion
39999
+ //#region src/notification-center/notification-label-layout.ts
40000
+ /**
40001
+ * Where each box's caption goes on a notification picture — pure geometry,
40002
+ * rects in, placements out (D897 follow-up, 2026-10-22).
40003
+ *
40004
+ * A caption drawn INSIDE its box used to cover the subject whole on a small
40005
+ * box, and a caption next to a crowd landed on a neighbour's box. The rule:
40006
+ *
40007
+ * 1. Try, in order: OUTSIDE above the box (inside the picture), OUTSIDE below
40008
+ * it, then INSIDE its top edge — the last only when the box is big enough
40009
+ * that the plate is a thin band of it: plate height ≤
40010
+ * {@link NC_LABEL_INSIDE_MAX_HEIGHT_FRACTION} of the box height and plate
40011
+ * width ≤ the box width. A fifth of the height leaves the head and torso of
40012
+ * an upright person, or most of a vehicle, visible; anything taller is the
40013
+ * caption hiding what it names.
40014
+ * 2. A candidate is refused when its plate intersects ANY other box drawn on
40015
+ * the picture, or a plate already placed.
40016
+ * 3. The subject is placed first; the other tracks follow by score, highest
40017
+ * first, so a crowd never costs the subject its caption.
40018
+ * 4. The font never shrinks to make a plate fit: no fitting candidate means
40019
+ * no caption for that box. A box without its caption still carries its
40020
+ * outline and colour.
40021
+ *
40022
+ * Coordinates are the picture's own pixels, whatever it is (a scene or a crop).
40023
+ */
40024
+ /** The inside placement is allowed only when the plate is at most this fraction of the box height. */
40025
+ var NC_LABEL_INSIDE_MAX_HEIGHT_FRACTION = .2;
40026
+ /** A caption is never drawn smaller than this, in output pixels. */
40027
+ var NC_LABEL_MIN_FONT_PX = 14;
40028
+ /** The dark plate a caption sits on, for this text at this font size. */
40029
+ function labelPlateSize(label, fontSize) {
40030
+ return {
40031
+ w: Math.round(label.length * fontSize * .62 + fontSize * .8),
40032
+ h: Math.round(fontSize * 1.35)
40033
+ };
40034
+ }
40035
+ /** Positive-area overlap; rectangles that only touch do not intersect. */
40036
+ function intersects(a, b) {
40037
+ return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
40038
+ }
40039
+ function captionOrder(boxes) {
40040
+ const rank = (b) => b.role === "subject" ? 0 : 1;
40041
+ return boxes.map((_, i) => i).sort((i, j) => {
40042
+ const a = boxes[i];
40043
+ const b = boxes[j];
40044
+ if (a === void 0 || b === void 0) return 0;
40045
+ return rank(a) - rank(b) || (b.score ?? 0) - (a.score ?? 0) || i - j;
40046
+ });
40047
+ }
40048
+ function candidates(box, plate, width, height) {
40049
+ if (plate.w > width) return [];
40050
+ const x = Math.min(box.x, Math.max(0, width - plate.w));
40051
+ const out = [];
40052
+ if (box.y - plate.h >= 0) out.push({
40053
+ x,
40054
+ y: box.y - plate.h,
40055
+ ...plate,
40056
+ where: "above"
40057
+ });
40058
+ if (box.y + box.h + plate.h <= height) out.push({
40059
+ x,
40060
+ y: box.y + box.h,
40061
+ ...plate,
40062
+ where: "below"
40063
+ });
40064
+ if (plate.h <= box.h * NC_LABEL_INSIDE_MAX_HEIGHT_FRACTION && plate.w <= box.w) out.push({
40065
+ x,
40066
+ y: box.y,
40067
+ ...plate,
40068
+ where: "inside"
40069
+ });
40070
+ return out;
40071
+ }
40072
+ /** Place every caption by the rule above. */
40073
+ function layoutBoxLabels(input) {
40074
+ const { width, height, boxes } = input;
40075
+ const fontSize = Math.max(NC_LABEL_MIN_FONT_PX, input.fontSize);
40076
+ const placements = /* @__PURE__ */ new Map();
40077
+ let dropped = 0;
40078
+ for (const i of captionOrder(boxes)) {
40079
+ const box = boxes[i];
40080
+ if (box === void 0 || box.label.length === 0) continue;
40081
+ const chosen = candidates(box, labelPlateSize(box.label, fontSize), width, height).find((c) => !boxes.some((other, j) => j !== i && intersects(c, other)) && ![...placements.values()].some((p) => intersects(c, p)));
40082
+ if (chosen === void 0) dropped += 1;
40083
+ else placements.set(i, chosen);
40084
+ }
40085
+ return {
40086
+ placements: boxes.map((_, i) => placements.get(i) ?? null),
40087
+ placed: placements.size,
40088
+ dropped
40089
+ };
40090
+ }
40091
+ //#endregion
39738
40092
  //#region src/notification-center/trigger-zone-still.ts
39739
40093
  /**
39740
40094
  * How far a candidate's instant may sit from the nearest trail point before the
@@ -39857,6 +40211,15 @@ var NC_IMAGE_OTHER_COLOR = "#f2f2f2";
39857
40211
  var NC_IMAGE_ZONE_COLOR = "#29b6f6";
39858
40212
  /** Two tracks whose boxes overlap this much at one instant are the same subject (a fused id). */
39859
40213
  var SAME_SUBJECT_IOU = .8;
40214
+ /**
40215
+ * A box of the SUBJECT'S CLASS overlapping the subject this much at one
40216
+ * instant is the subject itself — an unfused piece L2 has not joined yet
40217
+ * (D683 fuses 10–20 s later; a push leaves ~6 s after the trigger). The
40218
+ * detector's per-class NMS (`NMS_IOU_DEFAULT` 0.45) keeps one of two
40219
+ * same-class boxes above 0.45 in a frame, so two REAL subjects of one class
40220
+ * cannot overlap past it; 0.5 leaves a margin over that.
40221
+ */
40222
+ var NC_IMAGE_SAME_CLASS_SUBJECT_IOU = .5;
39860
40223
  var JPEG_QUALITY = 85;
39861
40224
  /** The caption of a box: class (or label) and confidence — "dog 87%". Language-free. */
39862
40225
  function imageLabelText(name, confidence) {
@@ -39889,15 +40252,21 @@ function toPixels(box, width, height) {
39889
40252
  h: Math.max(1, Math.min(Math.round(box.h * height), height - y))
39890
40253
  };
39891
40254
  }
40255
+ /** {@link notificationOverlay}'s SVG alone. */
40256
+ function notificationOverlaySvg(input) {
40257
+ return notificationOverlay(input).svg;
40258
+ }
39892
40259
  /**
39893
- * The SVG drawn over a scene picture of `width` × `height`. The subject is
39894
- * drawn LAST so a crowd never hides it. Captions sit on a dark plate above
39895
- * the box (inside it when the box touches the top edge), white text.
40260
+ * The SVG drawn over a picture of `width` × `height`. The subject is drawn
40261
+ * LAST so a crowd never hides it. Captions sit on a dark plate, white text,
40262
+ * where `layoutBoxLabels` puts them — outside above, outside below, inside a
40263
+ * box only when it covers a thin band of it, never on another box or caption,
40264
+ * and nowhere at all rather than over the subject.
39896
40265
  */
39897
- function notificationOverlaySvg(input) {
40266
+ function notificationOverlay(input) {
39898
40267
  const { width, height } = input;
39899
40268
  const strokes = overlayStrokes(width, height);
39900
- const fontSize = Math.max(14, Math.round(height / 36));
40269
+ const fontSize = Math.max(NC_LABEL_MIN_FONT_PX, Math.round(height / 36));
39901
40270
  const parts = [];
39902
40271
  for (const zone of input.zones) {
39903
40272
  if (zone.length < 3) continue;
@@ -39909,19 +40278,29 @@ function notificationOverlaySvg(input) {
39909
40278
  parts.push(`<polyline data-role="subject-path" points="${require_clip_model_registry.subjectPathSvgPoints(run, width, height)}" fill="none" stroke="${NC_IMAGE_SUBJECT_COLOR}" stroke-width="${String(strokes.subject)}" stroke-linejoin="round" stroke-linecap="round" stroke-opacity="0.9"/>`);
39910
40279
  }
39911
40280
  const ordered = [...input.boxes.filter((b) => b.role === "other"), ...input.boxes.filter((b) => b.role === "subject")];
39912
- for (const box of ordered) {
40281
+ const layout = layoutBoxLabels({
40282
+ width,
40283
+ height,
40284
+ fontSize,
40285
+ boxes: ordered
40286
+ });
40287
+ const plates = [];
40288
+ ordered.forEach((box, i) => {
39913
40289
  const subject = box.role === "subject";
39914
40290
  const color = subject ? NC_IMAGE_SUBJECT_COLOR : NC_IMAGE_OTHER_COLOR;
39915
40291
  const stroke = subject ? strokes.subject : strokes.other;
39916
40292
  parts.push(`<rect data-role="${box.role}" x="${String(box.x)}" y="${String(box.y)}" width="${String(box.w)}" height="${String(box.h)}" fill="none" stroke="${color}" stroke-width="${String(stroke)}"/>`);
39917
- if (box.label.length === 0) continue;
39918
- const plateH = Math.round(fontSize * 1.35);
39919
- const plateW = Math.round(box.label.length * fontSize * .62 + fontSize * .8);
39920
- const plateY = box.y - plateH >= 0 ? box.y - plateH : box.y;
39921
- const plateX = Math.min(box.x, Math.max(0, width - plateW));
39922
- parts.push(`<rect data-role="${box.role}-plate" x="${String(plateX)}" y="${String(plateY)}" width="${String(plateW)}" height="${String(plateH)}" fill="#000000" fill-opacity="0.72" stroke="${color}" stroke-width="${String(subject ? 2 : 1)}"/>`, `<text data-role="${box.role}-label" x="${String(plateX + Math.round(fontSize * .4))}" y="${String(plateY + Math.round(plateH * .75))}" font-family="DejaVu Sans, Helvetica, Arial, sans-serif" font-size="${String(fontSize)}" font-weight="${subject ? "700" : "400"}" fill="#ffffff">${escapeXml$2(box.label)}</text>`);
39923
- }
39924
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${String(width)}" height="${String(height)}">${parts.join("")}</svg>`;
40293
+ const plate = layout.placements[i];
40294
+ if (plate === null || plate === void 0) return;
40295
+ plates.push(`<rect data-role="${box.role}-plate" x="${String(plate.x)}" y="${String(plate.y)}" width="${String(plate.w)}" height="${String(plate.h)}" fill="#000000" fill-opacity="0.72" stroke="${color}" stroke-width="${String(subject ? 2 : 1)}"/>`, `<text data-role="${box.role}-label" x="${String(plate.x + Math.round(fontSize * .4))}" y="${String(plate.y + Math.round(plate.h * .75))}" font-family="DejaVu Sans, Helvetica, Arial, sans-serif" font-size="${String(fontSize)}" font-weight="${subject ? "700" : "400"}" fill="#ffffff">${escapeXml$2(box.label)}</text>`);
40296
+ });
40297
+ return {
40298
+ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${String(width)}" height="${String(height)}">${[...parts, ...plates].join("")}</svg>`,
40299
+ labels: {
40300
+ placed: layout.placed,
40301
+ dropped: layout.dropped
40302
+ }
40303
+ };
39925
40304
  }
39926
40305
  /** The pixel rectangle a `crop` cuts from a `width` × `height` frame, or null when too small. */
39927
40306
  function subjectCropRect(box, width, height) {
@@ -39951,11 +40330,18 @@ function iou$4(a, b) {
39951
40330
  }
39952
40331
  /**
39953
40332
  * The tracks to draw thin: every track at the instant except the subject —
39954
- * the same id, or a box that is the subject's (a fused id reads under its
39955
- * survivor's name).
39956
- */
39957
- function otherTracks(tracks, subjectTrackId, subjectBox) {
39958
- return tracks.filter((t) => t.trackId !== subjectTrackId && (subjectBox === null || iou$4(t.bbox, subjectBox) < SAME_SUBJECT_IOU));
40333
+ * the same id, a box that is the subject's (a fused id reads under its
40334
+ * survivor's name), or a box of the subject's class over it past what NMS
40335
+ * lets two real subjects share (an unfused piece of the same subject).
40336
+ */
40337
+ function otherTracks(tracks, subjectTrackId, subjectBox, subjectClassName) {
40338
+ return tracks.filter((t) => {
40339
+ if (t.trackId === subjectTrackId) return false;
40340
+ if (subjectBox === null) return true;
40341
+ const overlap = iou$4(t.bbox, subjectBox);
40342
+ if (overlap >= SAME_SUBJECT_IOU) return false;
40343
+ return !(t.className === subjectClassName && overlap >= NC_IMAGE_SAME_CLASS_SUBJECT_IOU);
40344
+ });
39959
40345
  }
39960
40346
  /**
39961
40347
  * What picture a ROW asks for. A row written since D897 carries `mediaImage`;
@@ -39972,9 +40358,15 @@ function payloadImageMode(payload) {
39972
40358
  if (payload.media === "keyFrame") return "clean";
39973
40359
  return "crop";
39974
40360
  }
39975
- /** The zone ids a row's picture outlines: the zones that fired it, when it asked. */
40361
+ /**
40362
+ * The zone ids a row's picture outlines: the zone(s) THIS event triggered,
40363
+ * when it asked — never every zone the rule lists (a dog in Uscio drew Aiuola
40364
+ * too). A row that asked but froze no `matchedZoneIds` (written before the
40365
+ * field) draws none: an outline of a zone the subject never entered is a
40366
+ * picture that lies about its own reason.
40367
+ */
39976
40368
  function payloadZoneOverlayIds(payload) {
39977
- if (payload.mediaShowTriggerZone === true) return payload.triggerZoneIds ?? [];
40369
+ if (payload.mediaShowTriggerZone === true) return payload.matchedZoneIds ?? [];
39978
40370
  if (payload.mediaImage === void 0) return payload.mediaZoneIds ?? [];
39979
40371
  return [];
39980
40372
  }
@@ -40063,7 +40455,8 @@ async function renderNotificationImage(req) {
40063
40455
  const boxes = req.mode === "scene-boxes" ? [...req.others.map((o) => ({
40064
40456
  ...toPixels(o, outW, outH),
40065
40457
  role: "other",
40066
- label: o.label
40458
+ label: o.label,
40459
+ ...o.confidence !== void 0 ? { score: o.confidence } : {}
40067
40460
  })), ...req.subject !== null ? [{
40068
40461
  ...toPixels(req.subject, outW, outH),
40069
40462
  role: "subject",
@@ -40081,16 +40474,18 @@ async function renderNotificationImage(req) {
40081
40474
  height: outH,
40082
40475
  fit: "fill"
40083
40476
  });
40477
+ let labels;
40084
40478
  if (boxes.length > 0 || zones.length > 0 || paths.length > 0) {
40085
- const svg = notificationOverlaySvg({
40479
+ const overlay = notificationOverlay({
40086
40480
  width: outW,
40087
40481
  height: outH,
40088
40482
  boxes,
40089
40483
  zones,
40090
40484
  paths
40091
40485
  });
40486
+ if (boxes.length > 0) labels = overlay.labels;
40092
40487
  pipeline = pipeline.composite([{
40093
- input: Buffer.from(svg),
40488
+ input: Buffer.from(overlay.svg),
40094
40489
  top: 0,
40095
40490
  left: 0
40096
40491
  }]);
@@ -40100,7 +40495,8 @@ async function renderNotificationImage(req) {
40100
40495
  return {
40101
40496
  bytes: toArrayBufferBytes(out),
40102
40497
  name: `${rendered}.jpg`,
40103
- rendered
40498
+ rendered,
40499
+ ...labels !== void 0 ? { labels } : {}
40104
40500
  };
40105
40501
  }
40106
40502
  var NcNotificationImageStage = class {
@@ -40174,10 +40570,29 @@ var NcNotificationImageStage = class {
40174
40570
  ...subjectBox,
40175
40571
  label: imageLabelText(payload.subject.label ?? payload.subject.className, payload.subject.confidence)
40176
40572
  } : null;
40177
- const others = mode === "scene-boxes" ? otherTracks(await this.tracksAt(camera, source.timestamp), subjectTrackId, subjectBox).map((t) => ({
40573
+ const othersDrawn = mode === "scene-boxes" ? otherTracks(await this.tracksAt(camera, source.timestamp), subjectTrackId, subjectBox, payload.subject.className) : [];
40574
+ if (mode === "scene-boxes") this.deps.logger.info("scene-boxes: other boxes drawn", {
40575
+ tags: { deviceId: camera },
40576
+ meta: {
40577
+ ruleId: entry.ruleId,
40578
+ recordId: entry.recordId,
40579
+ instant: source.timestamp,
40580
+ drawn: othersDrawn.map((t) => `${t.trackId}/${t.className}/dt=${t.dtMs ?? "?"}ms/${t.interpolated === true ? "interpolated" : "exact"}`).join(",")
40581
+ }
40582
+ });
40583
+ const others = mode === "scene-boxes" ? othersDrawn.map((t) => ({
40178
40584
  ...t.bbox,
40179
- label: imageLabelText(t.label ?? t.className, t.confidence)
40585
+ label: imageLabelText(t.label ?? t.className, t.confidence),
40586
+ ...t.confidence !== void 0 ? { confidence: t.confidence } : {}
40180
40587
  })) : [];
40588
+ if (mode !== "crop" && input.withSubject && payload.mediaShowTriggerZone === true && payload.matchedZoneIds === void 0) this.deps.logger.info("trigger zone not drawn — the row never froze which zone fired", {
40589
+ tags: { deviceId: camera },
40590
+ meta: {
40591
+ ruleId: entry.ruleId,
40592
+ recordId: entry.recordId,
40593
+ ruleZoneIds: (payload.triggerZoneIds ?? []).join(",")
40594
+ }
40595
+ });
40181
40596
  const zones = mode === "crop" || zoneIds.length === 0 ? [] : await this.zonePolygons(camera, zoneIds);
40182
40597
  try {
40183
40598
  const image = await this.render({
@@ -40190,6 +40605,7 @@ var NcNotificationImageStage = class {
40190
40605
  });
40191
40606
  const reason = image.rendered === mode ? void 0 : mode === "crop" ? "no-subject-box" : "no-tracks-at-instant";
40192
40607
  if (reason !== void 0) this.reportFallback(input, mode, reason);
40608
+ if (image.labels !== void 0) this.reportLabels(input, image.labels);
40193
40609
  return reason !== void 0 ? {
40194
40610
  ...image,
40195
40611
  reason
@@ -40238,6 +40654,19 @@ var NcNotificationImageStage = class {
40238
40654
  }
40239
40655
  });
40240
40656
  }
40657
+ /** ONE line per rendered picture: how many captions it carries and how many had no clear spot. */
40658
+ reportLabels(input, labels) {
40659
+ const camera = input.camera === "composite" ? input.entry.payload.subject.deviceId : input.camera;
40660
+ this.deps.logger.debug("notification picture captions laid out", {
40661
+ tags: { deviceId: camera },
40662
+ meta: {
40663
+ ruleId: input.entry.ruleId,
40664
+ recordId: input.entry.recordId,
40665
+ placed: labels.placed,
40666
+ dropped: labels.dropped
40667
+ }
40668
+ });
40669
+ }
40241
40670
  /** The subject's trail, read once per render for its box AND its path. */
40242
40671
  async subjectTrail(camera, trackId) {
40243
40672
  const read = this.deps.getSubjectTrail;
@@ -46931,6 +47360,27 @@ function isRecord(value) {
46931
47360
  return typeof value === "object" && value !== null && !Array.isArray(value);
46932
47361
  }
46933
47362
  /**
47363
+ * Incident 2026-10-22: a row stored with `groupingSec: 0` failed `NcRuleSchema` (min
47364
+ * {@link NC_GROUPING_MIN_SEC}) and the WHOLE rule was skipped as malformed on every load — a
47365
+ * system-event rule on which a window cannot even apply vanished from the engine and from
47366
+ * `listRules`. A non-positive (or non-numeric) window only ever meant "no window": dropped. A
47367
+ * positive one outside the range is clamped. One bad optional field never costs the rule.
47368
+ */
47369
+ function repairStoredGroupingSec(value) {
47370
+ if (value === void 0) return { kind: "unchanged" };
47371
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return {
47372
+ kind: "repaired",
47373
+ from: value,
47374
+ to: null
47375
+ };
47376
+ const clamped = clampGroupingSec(value);
47377
+ return clamped === value ? { kind: "unchanged" } : {
47378
+ kind: "repaired",
47379
+ from: value,
47380
+ to: clamped
47381
+ };
47382
+ }
47383
+ /**
46934
47384
  * Project a RAW stored rule value (`data['rule']`, before any Zod parse). A value that is not
46935
47385
  * an object, or has no string `delivery`, is returned untouched: the schema parse that follows
46936
47386
  * rejects it exactly as before, and a projection must never be what decides a row is valid.
@@ -46938,9 +47388,12 @@ function isRecord(value) {
46938
47388
  function projectRawStoredRule(raw) {
46939
47389
  if (!isRecord(raw) || typeof raw["delivery"] !== "string") return {
46940
47390
  candidate: raw,
46941
- projection: { kind: "unchanged" }
47391
+ projection: { kind: "unchanged" },
47392
+ groupingRepair: { kind: "unchanged" }
46942
47393
  };
46943
- const groupingSec = raw["groupingSec"];
47394
+ const groupingRepair = repairStoredGroupingSec(raw["groupingSec"]);
47395
+ const repaired = withRepairedGrouping(raw, groupingRepair);
47396
+ const groupingSec = repaired["groupingSec"];
46944
47397
  const projection = projectRetiredRuleFields({
46945
47398
  delivery: raw["delivery"],
46946
47399
  conditions: isRecord(raw["conditions"]) ? raw["conditions"] : {},
@@ -46949,15 +47402,25 @@ function projectRawStoredRule(raw) {
46949
47402
  ...typeof groupingSec === "number" ? { groupingSec } : {}
46950
47403
  });
46951
47404
  if (projection.kind === "unchanged" || projection.groupingSec === void 0) return {
46952
- candidate: raw,
46953
- projection
47405
+ candidate: repaired,
47406
+ projection,
47407
+ groupingRepair
46954
47408
  };
46955
47409
  return {
46956
47410
  candidate: {
46957
- ...raw,
47411
+ ...repaired,
46958
47412
  groupingSec: projection.groupingSec
46959
47413
  },
46960
- projection
47414
+ projection,
47415
+ groupingRepair
47416
+ };
47417
+ }
47418
+ function withRepairedGrouping(raw, repair) {
47419
+ if (repair.kind === "unchanged") return raw;
47420
+ const { groupingSec: _dropped, ...rest } = raw;
47421
+ return repair.to === null ? rest : {
47422
+ ...rest,
47423
+ groupingSec: repair.to
46961
47424
  };
46962
47425
  }
46963
47426
  /**
@@ -47010,16 +47473,44 @@ var LOAD_LIMIT = 1e4;
47010
47473
  * Parse a raw body object (as returned from storage — may contain retired keys)
47011
47474
  * into an `NcRule` applying the legacy field projection.
47012
47475
  */
47013
- function bodyToRule(id, raw, onProjection) {
47014
- const { candidate, projection } = projectRawStoredRule(raw);
47476
+ function bodyToRule(id, raw, onProjection, onGroupingRepair) {
47477
+ const parsed = parseStoredRule(id, raw, onProjection, onGroupingRepair);
47478
+ return parsed.ok ? parsed.rule : null;
47479
+ }
47480
+ function parseStoredRule(id, raw, onProjection, onGroupingRepair, onClassWidening) {
47481
+ const { candidate, projection, groupingRepair } = projectRawStoredRule(raw);
47015
47482
  const parsed = require_clip_model_registry.NcRuleSchema.safeParse({
47016
47483
  ...candidate,
47017
47484
  id
47018
47485
  });
47019
- if (!parsed.success) return null;
47486
+ if (!parsed.success) {
47487
+ const issue = parsed.error.issues[0];
47488
+ return {
47489
+ ok: false,
47490
+ reason: issue === void 0 ? "invalid" : `${issue.path.join(".") || "(root)"}: ${issue.message}`
47491
+ };
47492
+ }
47020
47493
  if (projection.kind === "projected") onProjection?.(id, projection);
47021
- return withNormalizedMedia(parsed.data);
47494
+ if (groupingRepair.kind === "repaired") onGroupingRepair?.(id, groupingRepair);
47495
+ const media = withNormalizedMedia(parsed.data);
47496
+ const rule = require_clip_model_registry.withSystemEventClasses(media);
47497
+ if (rule !== media) onClassWidening?.(id, media, rule);
47498
+ return {
47499
+ ok: true,
47500
+ rule
47501
+ };
47502
+ }
47503
+ /**
47504
+ * D906: what a rule is stored as — its media policy folded (D897) and, on an
47505
+ * INSTANT system rule, every paired class it touches completed with both
47506
+ * edges. Applied on every WRITE; the load applies the same through
47507
+ * {@link parseStoredRule}, which also says once that it widened a rule.
47508
+ */
47509
+ function normalizeStoredRule(rule) {
47510
+ return require_clip_model_registry.withSystemEventClasses(withNormalizedMedia(rule));
47022
47511
  }
47512
+ /** At most this many skipped rows are NAMED in the WARN line; the count is always whole. */
47513
+ var SKIPPED_ROWS_NAMED_MAX = 10;
47023
47514
  /**
47024
47515
  * D897: the rule with its media policy in the stored shape — `image` set, the
47025
47516
  * legacy `attach` / `frame` / `zoneCrop` folded into it. Applied on LOAD (a read
@@ -47096,6 +47587,8 @@ var NcRuleStore = class NcRuleStore {
47096
47587
  now;
47097
47588
  newId;
47098
47589
  identityIdsByName;
47590
+ /** Rows the last successful {@link load} skipped; `null` before one succeeded. */
47591
+ skipped = null;
47099
47592
  /** Has a {@link load} ever completed? See the note there. */
47100
47593
  loaded = false;
47101
47594
  /** Legacy detection rules naming sounds already reported (D690) — once each. */
@@ -47107,6 +47600,10 @@ var NcRuleStore = class NcRuleStore {
47107
47600
  pendingProjections = /* @__PURE__ */ new Map();
47108
47601
  /** Rules whose projection was already logged in this process (D692 §9) — once each. */
47109
47602
  projectionLogged = /* @__PURE__ */ new Set();
47603
+ /** Rules whose out-of-range `groupingSec` repair was already logged — once each. */
47604
+ groupingRepairLogged = /* @__PURE__ */ new Set();
47605
+ /** Rules whose class widening (D906) was already logged — once each. */
47606
+ classWideningLogged = /* @__PURE__ */ new Set();
47110
47607
  constructor(deps) {
47111
47608
  this.logger = deps.logger;
47112
47609
  this.now = deps.now ?? (() => Date.now());
@@ -47141,20 +47638,29 @@ var NcRuleStore = class NcRuleStore {
47141
47638
  try {
47142
47639
  const { records } = await this.kind.query({ limit: LOAD_LIMIT });
47143
47640
  const next = /* @__PURE__ */ new Map();
47144
- let skipped = 0;
47641
+ const skippedRows = [];
47145
47642
  for (const row of records) {
47146
- const rule = bodyToRule(row.id, row.data, (ruleId, projection) => {
47643
+ const parsed = parseStoredRule(row.id, row.data, (ruleId, projection) => {
47147
47644
  this.pendingProjections.set(ruleId, projection);
47148
- });
47149
- if (rule === null) {
47150
- skipped += 1;
47645
+ }, (ruleId, repair) => this.logGroupingRepair(ruleId, repair), (ruleId, before, after) => this.logClassWidening(ruleId, before, after));
47646
+ if (!parsed.ok) {
47647
+ const name = row.data["name"];
47648
+ skippedRows.push({
47649
+ ruleId: row.id,
47650
+ name: typeof name === "string" ? name : null,
47651
+ reason: parsed.reason
47652
+ });
47151
47653
  continue;
47152
47654
  }
47153
- next.set(row.id, rule);
47655
+ next.set(row.id, parsed.rule);
47154
47656
  }
47155
47657
  this.byId.clear();
47156
47658
  for (const [id, rule] of next) this.byId.set(id, rule);
47157
- if (skipped > 0) this.logger.warn("notification rule rows skipped as malformed", { meta: { skipped } });
47659
+ this.skipped = skippedRows;
47660
+ if (skippedRows.length > 0) this.logger.warn("notification rule rows skipped as malformed", { meta: {
47661
+ skipped: skippedRows.length,
47662
+ rows: skippedRows.slice(0, SKIPPED_ROWS_NAMED_MAX)
47663
+ } });
47158
47664
  this.loaded = true;
47159
47665
  } catch (err) {
47160
47666
  this.logger.warn("notification rules load failed — keeping rules already in memory", { meta: {
@@ -47166,6 +47672,27 @@ var NcRuleStore = class NcRuleStore {
47166
47672
  for (const id of warnLegacyAudioClassRules([...this.byId.values()], this.audioClassWarned, this.logger)) this.audioClassWarned.add(id);
47167
47673
  this.logger.debug("notification rules loaded", { meta: { rules: this.byId.size } });
47168
47674
  }
47675
+ /** Say, once per process per rule, that an instant rule was read as its whole class (D906). */
47676
+ logClassWidening(ruleId, before, after) {
47677
+ if (this.classWideningLogged.has(ruleId)) return;
47678
+ this.classWideningLogged.add(ruleId);
47679
+ const had = new Set(before.conditions.systemEvent?.kinds ?? []);
47680
+ this.logger.info("instant system rule read as its whole event class — both edges", { meta: {
47681
+ ruleId,
47682
+ rule: after.name,
47683
+ added: (after.conditions.systemEvent?.kinds ?? []).filter((kind) => !had.has(kind))
47684
+ } });
47685
+ }
47686
+ /** Say, once per process per rule, that an out-of-range stored `groupingSec` was repaired. */
47687
+ logGroupingRepair(ruleId, repair) {
47688
+ if (this.groupingRepairLogged.has(ruleId)) return;
47689
+ this.groupingRepairLogged.add(ruleId);
47690
+ this.logger.warn("rule carries an out-of-range groupingSec — repaired on read", { meta: {
47691
+ ruleId,
47692
+ from: repair.from,
47693
+ to: repair.to
47694
+ } });
47695
+ }
47169
47696
  /** Log, once per process per rule, what `bodyToRule` projected from a retired field. */
47170
47697
  logPendingProjections() {
47171
47698
  for (const [ruleId, projection] of this.pendingProjections) {
@@ -47234,6 +47761,13 @@ var NcRuleStore = class NcRuleStore {
47234
47761
  * camera's ring) must ask this first: before the first load the empty list
47235
47762
  * is not a fact, and a failed load leaves whatever was already mirrored.
47236
47763
  */
47764
+ /**
47765
+ * The rows the last SUCCESSFUL load could not parse — `null` until one has
47766
+ * succeeded, so "not read yet" never reads as "nothing broken" (D315).
47767
+ */
47768
+ skippedRows() {
47769
+ return this.skipped;
47770
+ }
47237
47771
  isLoaded() {
47238
47772
  return this.loaded;
47239
47773
  }
@@ -47263,7 +47797,7 @@ var NcRuleStore = class NcRuleStore {
47263
47797
  /** Create a new rule. `createdBy` is the SERVER-injected caller userId. */
47264
47798
  async create(input, createdBy) {
47265
47799
  const now = this.now();
47266
- const rule = withNormalizedMedia({
47800
+ const rule = normalizeStoredRule({
47267
47801
  ...input,
47268
47802
  id: this.newId(),
47269
47803
  createdBy,
@@ -47308,7 +47842,7 @@ var NcRuleStore = class NcRuleStore {
47308
47842
  createdAt: existing.createdAt,
47309
47843
  updatedAt: this.now()
47310
47844
  };
47311
- const updated = withNormalizedMedia(require_clip_model_registry.NcRuleSchema.parse(candidate));
47845
+ const updated = normalizeStoredRule(require_clip_model_registry.NcRuleSchema.parse(candidate));
47312
47846
  await this.write(updated);
47313
47847
  return updated;
47314
47848
  }
@@ -47348,7 +47882,7 @@ var NcRuleStore = class NcRuleStore {
47348
47882
  id: ruleId
47349
47883
  } : candidate);
47350
47884
  if (!parsed.success) return existing;
47351
- const next = withNormalizedMedia(parsed.data);
47885
+ const next = normalizeStoredRule(parsed.data);
47352
47886
  const { id: _omit, ...data } = next;
47353
47887
  await this.kind.put({
47354
47888
  id: ruleId,
@@ -53377,6 +53911,104 @@ var NcZoneOwnershipCache = class {
53377
53911
  }
53378
53912
  };
53379
53913
  //#endregion
53914
+ //#region src/notification-center/rule-health.ts
53915
+ var EMPTY_SNAPSHOT = {
53916
+ timelapse: [],
53917
+ summary: [],
53918
+ alarm: null,
53919
+ mutedDeviceIds: []
53920
+ };
53921
+ function fingerprintOf(reasons) {
53922
+ return reasons.map((r) => `${r.code}:${[...r.ids].sort().join(",")}`).join("|");
53923
+ }
53924
+ /** The deleted subjects a rule still names, from the prune's `kept*` records. */
53925
+ function deletedReasons(rule, prunes) {
53926
+ const snapshot = {
53927
+ ...EMPTY_SNAPSHOT,
53928
+ rules: [rule]
53929
+ };
53930
+ const devices = referencedDeviceIds(snapshot);
53931
+ const targets = referencedTargetIds(snapshot);
53932
+ const deadDevices = /* @__PURE__ */ new Set();
53933
+ const deadTargets = /* @__PURE__ */ new Set();
53934
+ for (const p of prunes) {
53935
+ if (p.kind !== "rule" || p.ruleId !== rule.id) continue;
53936
+ if (p.outcome !== "kept" && p.outcome !== "kept-disabled") continue;
53937
+ if (p.subjectKind === "device" && devices.has(Number(p.subject))) deadDevices.add(p.subject);
53938
+ if (p.subjectKind === "target" && targets.has(p.subject)) deadTargets.add(p.subject);
53939
+ }
53940
+ const reasons = [];
53941
+ if (deadDevices.size > 0) reasons.push({
53942
+ code: "device-deleted",
53943
+ ids: [...deadDevices].sort()
53944
+ });
53945
+ if (deadTargets.size > 0) reasons.push({
53946
+ code: "target-deleted",
53947
+ ids: [...deadTargets].sort()
53948
+ });
53949
+ return reasons;
53950
+ }
53951
+ var NcRuleHealthMonitor = class {
53952
+ verdicts = /* @__PURE__ */ new Map();
53953
+ /** Judge every rule once; return the edges to announce. */
53954
+ evaluate(inputs) {
53955
+ const next = /* @__PURE__ */ new Map();
53956
+ const seen = /* @__PURE__ */ new Set();
53957
+ if (inputs.skipped !== null) for (const row of inputs.skipped) {
53958
+ seen.add(row.ruleId);
53959
+ const unparseable = {
53960
+ code: "unparseable",
53961
+ ids: [],
53962
+ detail: row.reason
53963
+ };
53964
+ next.set(row.ruleId, this.verdict(row.name, unparseable, []));
53965
+ }
53966
+ if (inputs.rules !== null) for (const rule of inputs.rules) {
53967
+ seen.add(rule.id);
53968
+ const previous = this.verdicts.get(rule.id);
53969
+ const deleted = inputs.prunes !== null ? deletedReasons(rule, inputs.prunes) : previous?.deleted ?? [];
53970
+ const unparseable = inputs.skipped !== null ? null : previous?.unparseable ?? null;
53971
+ next.set(rule.id, this.verdict(rule.name, unparseable, deleted));
53972
+ }
53973
+ const edges = [];
53974
+ const answeredAll = inputs.rules !== null && inputs.skipped !== null;
53975
+ for (const [ruleId, previous] of this.verdicts) {
53976
+ if (seen.has(ruleId)) continue;
53977
+ if (!answeredAll) next.set(ruleId, previous);
53978
+ }
53979
+ for (const [ruleId, verdict] of next) {
53980
+ const previous = this.verdicts.get(ruleId);
53981
+ const reasons = reasonsOf(verdict);
53982
+ if (reasons.length > 0) {
53983
+ if (previous?.fingerprint !== verdict.fingerprint) edges.push({
53984
+ kind: "broken",
53985
+ ruleId,
53986
+ ruleName: verdict.name,
53987
+ reasons
53988
+ });
53989
+ } else if (previous !== void 0 && reasonsOf(previous).length > 0) edges.push({
53990
+ kind: "repaired",
53991
+ ruleId,
53992
+ ruleName: verdict.name
53993
+ });
53994
+ }
53995
+ this.verdicts.clear();
53996
+ for (const [ruleId, verdict] of next) if (reasonsOf(verdict).length > 0) this.verdicts.set(ruleId, verdict);
53997
+ return edges;
53998
+ }
53999
+ verdict(name, unparseable, deleted) {
54000
+ return {
54001
+ name,
54002
+ unparseable,
54003
+ deleted,
54004
+ fingerprint: fingerprintOf([...unparseable !== null ? [unparseable] : [], ...deleted])
54005
+ };
54006
+ }
54007
+ };
54008
+ function reasonsOf(verdict) {
54009
+ return [...verdict.unparseable !== null ? [verdict.unparseable] : [], ...verdict.deleted];
54010
+ }
54011
+ //#endregion
53380
54012
  //#region src/notification-center/index.ts
53381
54013
  /**
53382
54014
  * NotificationCenter — the P1 core of the Notification Center (spec
@@ -53443,7 +54075,7 @@ var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
53443
54075
  * media (the SOURCE rule of a combined alarm row), and a zone overlay is
53444
54076
  * frozen only when the rule asked for one AND fired on a zone.
53445
54077
  */
53446
- function mediaPolicyFields(mediaRule, cropZoneIds) {
54078
+ function mediaPolicyFields(mediaRule, cropZoneIds, matchedZoneIds) {
53447
54079
  const media = effectiveMediaPolicyForRule(mediaRule);
53448
54080
  const image = media.image ?? "crop";
53449
54081
  return {
@@ -53452,6 +54084,7 @@ function mediaPolicyFields(mediaRule, cropZoneIds) {
53452
54084
  ...media.showTriggerZone === true && cropZoneIds !== void 0 && cropZoneIds.length > 0 ? { mediaShowTriggerZone: true } : {},
53453
54085
  ...media.showSubjectPath === true && image !== "none" ? { mediaShowSubjectPath: true } : {},
53454
54086
  ...cropZoneIds !== void 0 ? { triggerZoneIds: [...cropZoneIds] } : {},
54087
+ ...matchedZoneIds !== void 0 ? { matchedZoneIds: [...matchedZoneIds] } : {},
53455
54088
  ...media.profile !== void 0 ? { mediaProfile: media.profile } : {},
53456
54089
  ...media.gif === true ? { mediaGif: true } : {},
53457
54090
  ...media.clip === true ? { mediaClip: true } : {},
@@ -53957,6 +54590,21 @@ function groupOrderInput(fate, triggerZoneIds) {
53957
54590
  * condition's ids, else an occupancy rule's one zone. A grouped member freezes the same
53958
54591
  * ids a plain row of the rule would.
53959
54592
  */
54593
+ /**
54594
+ * The zones of `cropZoneIds` this subject actually triggered — what the zone
54595
+ * condition saw (membership plus the crossed zone, `zonesVisitedBy`), on the
54596
+ * rule whose media the row carries. On a combined alarm row the subject is the
54597
+ * alarm, so the source match's frozen zones answer. An occupancy rule's one
54598
+ * zone is the zone the count edge is ABOUT, so it is its own trigger.
54599
+ */
54600
+ function matchedTriggerZoneIds(mediaRule, cropZoneIds, subject) {
54601
+ if (cropZoneIds === void 0) return void 0;
54602
+ if (mediaRule.conditions.zones === void 0) return [...cropZoneIds];
54603
+ const alarmSource = subject.systemEvent?.alarmSource;
54604
+ const visited = new Set(alarmSource !== void 0 ? alarmSource.zones ?? [] : subject.zones);
54605
+ if (alarmSource === void 0 && subject.crossing !== void 0) visited.add(subject.crossing.zoneId);
54606
+ return cropZoneIds.filter((id) => visited.has(id));
54607
+ }
53960
54608
  function ruleCropZoneIds(rule) {
53961
54609
  if (rule.conditions.zones !== void 0) return rule.conditions.zones.ids;
53962
54610
  const zoneId = rule.conditions.occupancy?.zoneId;
@@ -54110,6 +54758,8 @@ var NotificationCenter = class {
54110
54758
  * evaluating one.
54111
54759
  */
54112
54760
  pruneLedger;
54761
+ /** D905: which rules cannot work, announced once per distinct reason. */
54762
+ ruleHealth = new NcRuleHealthMonitor();
54113
54763
  /** The prune itself — see `device-prune-runner.ts`. */
54114
54764
  pruner;
54115
54765
  /**
@@ -57539,7 +58189,7 @@ var NotificationCenter = class {
57539
58189
  const subject = incoming.subject;
57540
58190
  const alarmSource = subject.systemEvent?.alarmSource;
57541
58191
  const sourceRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) : null;
57542
- const media = sourceRule !== null ? mediaPolicyFields(sourceRule, ruleCropZoneIds(sourceRule)) : { media: "none" };
58192
+ const media = sourceRule !== null ? mediaPolicyFields(sourceRule, ruleCropZoneIds(sourceRule), matchedTriggerZoneIds(sourceRule, ruleCropZoneIds(sourceRule), subject)) : { media: "none" };
57543
58193
  const mediaDeviceId = alarmSource?.deviceId ?? panel.deviceId;
57544
58194
  const entries = targets.map((targetId) => ({
57545
58195
  ruleId: NC_ALARM_ANNOUNCE_RULE_ID,
@@ -58745,6 +59395,7 @@ var NotificationCenter = class {
58745
59395
  const renderedZones = notificationZones(rule, subject);
58746
59396
  const confirm = frozenConfirmPolicy(rule, isSystemEvent);
58747
59397
  const cropZoneIds = ruleCropZoneIds(mediaRule);
59398
+ const matchedZoneIds = matchedTriggerZoneIds(mediaRule, cropZoneIds, subject);
58748
59399
  const allTargets = [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))];
58749
59400
  const linkedOwners = !noMedia && subject.linkedStills !== void 0 ? linkedMediaOwners(subject.linkedStills, linkedCameraMode(mediaRule.media)) : void 0;
58750
59401
  const groupV2 = extras?.groupV2 ?? (subject.systemEvent !== void 0 && !noMedia ? alarmIncidentGroup(subject.systemEvent, subject.timestamp) : void 0);
@@ -58755,7 +59406,7 @@ var NotificationCenter = class {
58755
59406
  ...origin === "synthetic" ? { synthetic: true } : {},
58756
59407
  priority: rule.priority,
58757
59408
  ...rule.template !== void 0 ? { template: rule.template } : {},
58758
- ...noMedia ? { media: "none" } : mediaPolicyFields(mediaRule, cropZoneIds),
59409
+ ...noMedia ? { media: "none" } : mediaPolicyFields(mediaRule, cropZoneIds, matchedZoneIds),
58759
59410
  ...confirm !== void 0 ? {
58760
59411
  confirm,
58761
59412
  ...cooldown !== void 0 ? { cooldown } : {},
@@ -59400,6 +60051,51 @@ var NotificationCenter = class {
59400
60051
  if (!this.pruneLedger.isLoaded()) await this.pruneLedger.load();
59401
60052
  await this.pruner.reconcileDevices();
59402
60053
  await this.pruner.reconcileTargets();
60054
+ this.announceRuleHealth();
60055
+ }
60056
+ /**
60057
+ * D905 — say which rules cannot work: a row the load skipped, or a rule still
60058
+ * naming a device / target the prune confirmed deleted. One edge per rule per
60059
+ * distinct set of reasons; a reading that did not answer changes nothing.
60060
+ */
60061
+ announceRuleHealth() {
60062
+ const edges = this.ruleHealth.evaluate({
60063
+ rules: this.rules.isLoaded() ? this.rules.list() : null,
60064
+ skipped: this.rules.skippedRows(),
60065
+ prunes: this.pruneLedger.isLoaded() ? this.pruner.listPrunes() : null
60066
+ });
60067
+ for (const edge of edges) {
60068
+ const source = {
60069
+ type: "addon",
60070
+ id: "notification-center"
60071
+ };
60072
+ if (edge.kind === "broken") {
60073
+ const [first] = edge.reasons;
60074
+ if (first === void 0) continue;
60075
+ const offendingIds = [...new Set(edge.reasons.flatMap((r) => r.ids))];
60076
+ this.logger.warn("notification rule cannot work — announced", { meta: {
60077
+ ruleId: edge.ruleId,
60078
+ rule: edge.ruleName,
60079
+ reasons: edge.reasons
60080
+ } });
60081
+ this.deps.emitBusEvent?.(require_clip_model_registry.createEvent(require_clip_model_registry.EventCategory.NotificationRuleBroken, source, {
60082
+ ruleId: edge.ruleId,
60083
+ ruleName: edge.ruleName,
60084
+ reason: first.code,
60085
+ reasons: edge.reasons,
60086
+ offendingIds
60087
+ }));
60088
+ } else {
60089
+ this.logger.info("notification rule works again — announced", { meta: {
60090
+ ruleId: edge.ruleId,
60091
+ rule: edge.ruleName
60092
+ } });
60093
+ this.deps.emitBusEvent?.(require_clip_model_registry.createEvent(require_clip_model_registry.EventCategory.NotificationRuleRepaired, source, {
60094
+ ruleId: edge.ruleId,
60095
+ ruleName: edge.ruleName
60096
+ }));
60097
+ }
60098
+ }
59403
60099
  }
59404
60100
  /**
59405
60101
  * `DeviceUnregistered` — a device was DELETED (only `removeDevice` emits it,
@@ -87251,7 +87947,12 @@ var RetrainCollectionService = class {
87251
87947
  reason: "unknown-collection"
87252
87948
  };
87253
87949
  }
87254
- const stored = await this.storeStill(input.deviceId, input.epochMs);
87950
+ const membershipOp = await this.deps.collections.addFrameOp({
87951
+ collectionId: input.collectionId,
87952
+ frameId: recordingFrameId(input.deviceId, input.epochMs),
87953
+ deviceId: input.deviceId
87954
+ });
87955
+ const stored = await this.storeStill(input.deviceId, input.epochMs, membershipOp === null ? [] : [membershipOp]);
87255
87956
  if (stored.kind === "refused") {
87256
87957
  this.deps.logger.info("retrain collection add refused", {
87257
87958
  tags,
@@ -87263,7 +87964,7 @@ var RetrainCollectionService = class {
87263
87964
  });
87264
87965
  return stored;
87265
87966
  }
87266
- await this.deps.collections.addFrame({
87967
+ if (stored.reused) await this.deps.collections.addFrame({
87267
87968
  collectionId: input.collectionId,
87268
87969
  frameId: stored.frame.frameId,
87269
87970
  deviceId: input.deviceId
@@ -87328,7 +88029,7 @@ var RetrainCollectionService = class {
87328
88029
  const ids = await this.deps.collections.listFrameIds(input.collectionId);
87329
88030
  return this.deps.retrain.toWireFrames(await this.deps.frames.getMany(ids));
87330
88031
  }
87331
- async storeStill(deviceId, epochMs) {
88032
+ async storeStill(deviceId, epochMs, withRow) {
87332
88033
  const existing = await this.deps.frames.get(recordingFrameId(deviceId, epochMs));
87333
88034
  if (existing !== null) return {
87334
88035
  kind: "stored",
@@ -87348,7 +88049,7 @@ var RetrainCollectionService = class {
87348
88049
  deviceId,
87349
88050
  epochMs,
87350
88051
  bytes: still.jpeg
87351
- });
88052
+ }, withRow);
87352
88053
  return stored.kind === "copied" ? {
87353
88054
  kind: "stored",
87354
88055
  frame: stored.frame,
@@ -89671,9 +90372,9 @@ var RetrainService = class {
89671
90372
  ...this.frameUrls(updated.frameId)
89672
90373
  };
89673
90374
  }
89674
- /** Un-select: annotations first, then the copy. Never the other way round —
89675
- * an annotation whose frame is gone is unopenable and unfixable. The frame's
89676
- * collection memberships go with it. */
90375
+ /** Un-select: the frame row, its annotations and its collection memberships
90376
+ * leave in one transaction (an annotation whose frame is gone is unopenable
90377
+ * and unfixable, so they never part); the blob is deleted after the row. */
89677
90378
  async deselectFrame(input) {
89678
90379
  const frame = await this.deps.frames.get(input.frameId);
89679
90380
  if (frame === null) {
@@ -89692,11 +90393,16 @@ var RetrainService = class {
89692
90393
  removedAnnotations: await this.removeFrame(frame)
89693
90394
  };
89694
90395
  }
89695
- /** Remove a frame from the dataset: annotations, memberships, then the copy. */
90396
+ /**
90397
+ * Remove a frame from the dataset. The frame row, its annotations and its
90398
+ * collection memberships leave in ONE transaction (a failure removes nothing
90399
+ * and throws); the blob is deleted after the row, never before.
90400
+ */
89696
90401
  async removeFrame(frame) {
89697
- const removedAnnotations = await this.deps.annotations.deleteForFrame(frame.frameId);
89698
- await this.deps.memberships?.forgetFrame(frame.frameId);
89699
- await this.deps.frames.deleteFrame(frame);
90402
+ const annotationOps = await this.deps.annotations.deleteOpsForFrame(frame.frameId);
90403
+ const membershipOps = await this.deps.memberships?.forgetFrameOps(frame.frameId) ?? [];
90404
+ await this.deps.frames.deleteFrame(frame, [...annotationOps, ...membershipOps]);
90405
+ const removedAnnotations = annotationOps.length;
89700
90406
  this.deps.logger.info("retrain frame removed from the dataset", {
89701
90407
  tags: { deviceId: frame.deviceId },
89702
90408
  meta: {
@@ -89890,7 +90596,8 @@ var RetrainService = class {
89890
90596
  * The guarded replace. A caller that names the revision it saw gets exactly
89891
90597
  * one attempt: a different current revision is a conflict, never a retry —
89892
90598
  * the point is that it must look again. A caller that names none asked for
89893
- * last-write-wins, so a race lost between our read and the transaction is
90599
+ * last-write-wins, so a race lost between our revision read and the records
90600
+ * `transact` that commits the save (a per-frame lock serialises the two) is
89894
90601
  * retried on the fresh revision, a bounded number of times.
89895
90602
  */
89896
90603
  async replaceGuarded(frame, input, drafts, dismissed) {
@@ -103517,12 +104224,16 @@ var NC_SYSTEM_EVENT_CATEGORIES = [
103517
104224
  require_clip_model_registry.EventCategory.WorkerOffline,
103518
104225
  require_clip_model_registry.EventCategory.PipelineNodeInferenceUnavailable,
103519
104226
  require_clip_model_registry.EventCategory.PipelineDetectionBlind,
104227
+ require_clip_model_registry.EventCategory.PipelineMotionNoisy,
104228
+ require_clip_model_registry.EventCategory.PipelineMotionCalm,
103520
104229
  require_clip_model_registry.EventCategory.RecordingHealthStalled,
103521
104230
  require_clip_model_registry.EventCategory.RecordingHealthOk,
103522
104231
  require_clip_model_registry.EventCategory.RecordingHealthDegraded,
103523
104232
  require_clip_model_registry.EventCategory.StorageLocationLow,
103524
104233
  require_clip_model_registry.EventCategory.StorageLocationCritical,
103525
104234
  require_clip_model_registry.EventCategory.StorageLocationOk,
104235
+ require_clip_model_registry.EventCategory.NotificationRuleBroken,
104236
+ require_clip_model_registry.EventCategory.NotificationRuleRepaired,
103526
104237
  require_clip_model_registry.EventCategory.AddonRunnerFailed,
103527
104238
  require_clip_model_registry.EventCategory.AddonStarted,
103528
104239
  require_clip_model_registry.EventCategory.AddonRestarted,
@@ -106533,6 +107244,18 @@ Object.freeze({
106533
107244
  "empty"
106534
107245
  ]
106535
107246
  },
107247
+ {
107248
+ "path": "filterCleaningDue",
107249
+ "valueType": "boolean"
107250
+ },
107251
+ {
107252
+ "path": "machineCareDue",
107253
+ "valueType": "boolean"
107254
+ },
107255
+ {
107256
+ "path": "machineCareRemainingRuns",
107257
+ "valueType": "number"
107258
+ },
106536
107259
  {
106537
107260
  "path": "halfLoad",
106538
107261
  "valueType": "boolean"
@@ -115553,6 +116276,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
115553
116276
  nodeAddons: (nodeId) => api.nodes.getNodeAddons.query({ nodeId })
115554
116277
  },
115555
116278
  readAlerts: () => api.alerts.list.query({ limit: 5e3 }),
116279
+ emitBusEvent: (event) => this.ctx.eventBus.emit(event),
115556
116280
  getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
115557
116281
  recognitionOutlook: ({ deviceId, trackId, className, labelKind, now }) => recognitionOutlook({
115558
116282
  className,
@@ -115826,7 +116550,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
115826
116550
  since: timestamp - toleranceMs,
115827
116551
  limit: NC_TRACKS_AT_INSTANT_LIMIT
115828
116552
  });
115829
- return tracksAtInstant([...live, ...persisted.tracks], timestamp, toleranceMs, this.lastFrameDimsByDevice.get(deviceId));
116553
+ return tracksAtInstant([...live, ...persisted.tracks], timestamp, toleranceMs, this.lastFrameDimsByDevice.get(deviceId), (drop) => this.ctx.logger.info("scene-boxes: other track not drawn", {
116554
+ tags: { deviceId },
116555
+ meta: {
116556
+ trackId: drop.trackId,
116557
+ className: drop.className,
116558
+ reason: drop.reason,
116559
+ instant: timestamp,
116560
+ ...drop.nearestDtMs !== void 0 ? { nearestDtMs: drop.nearestDtMs } : {}
116561
+ }
116562
+ }));
115830
116563
  },
115831
116564
  getDeviceName,
115832
116565
  getDeviceLocation,