@camstack/addon-post-analysis 1.2.621 → 1.2.622

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.
@@ -1,4 +1,4 @@
1
- import { $ as MediaFileKindEnum, $i as isDeviceScopedCap, $n as cosineSimilarity$1, $r as projectSubjectPath, $t as OpsLogEntrySchema, A as DeclaredDevices, Ai as systemEventFilterApplies, An as activateAlarmProfile, Ar as isEventOwnerType, At as NcAlarmSensorsInputSchema, B as FULL_IMAGE_BBOX, Bi as evaluateSensorEdge, Bn as applySecureOnArmEdit, Br as kebabToCamel, Bt as NcRuleUpdateInputSchema, C as ClearTrackRelationResultSchema, Ci as subKindsOf, Cn as TimelapseRulePatchSchema, Cr as isAlertEpisodeSeverity, Ct as NC_SNOOZE_MAX_MINUTES, D as DEFAULT_TIMELAPSE_PREVIEW_TEXT, Di as syncActiveAlarmProfileSettings, Dn as TrackRelationSchema, Dr as isDetectionMacroClass, Dt as NC_TEMPLATE_VARS, E as DEFAULT_EVENT_COLOR, Ei as summarizeReview, En as TrackRelationKindSchema, Er as isDefaultAlarmProfileId, Et as NC_TAXONOMY, F as EVENT_DENSITY_BATCH_MAX, Fi as vectorDimFromBase64, Fn as alertEpisodeKind, Fr as isRootSuppliableStepId, Ft as NcRuleActionSchema, G as ListTrackRelationsResultSchema, Gi as CamProfileSchema, Gn as buildEventKindDescriptor, Gr as normalizeNcMediaPolicy, Gt as NcSnoozeSuppressedSchema, H as LABEL_SUPPLY_MACRO, Hi as DeviceType, Hn as assertTimelapseCadences, Hr as mayWriteToLocation, Ht as NcSharedSequenceSchema, I as EVENT_KIND_BY_CAP, Ii as viewerLinkBaseOf, In as alertEpisodePayload, Ir as isScheduleActive, It as NcRuleInputSchema, J as MAX_BIRTH_DECISION_RECORDS, Ji as createEvent, Jn as calendarEventLocalSpan, Jr as parseAlertEpisodePayload, Jt as NcTaxonomySchema, K as LockStateSchema, Ki as RECORD_MAX_DATA_BYTES, Kn as buildSubjectPath, Kr as notificationRulesCapability, Kt as NcStateSummarySectionIdSchema, L as EVENT_OWNER_TYPES, Li as zoneAnalyticsCapability, Ln as applyBlockingEdit, Lr as isSystemDelivery, Lt as NcRulePatchSchema, M as EMBEDDING_MODEL_FAILED, Mi as systemEventSummarySplitOf, Mn as alarmDeviceSecureOnArm, Mr as isLabelSupplyStepId, Mt as NcAlarmSettingsPatchSchema, N as EMBEDDING_REQUIREMENTS_FAILED, Ni as templateCarriesSummary, Nn as alarmPanelCapability, Nr as isMediaOwnerType, Nt as NcConditionDescriptorSchema, O as DETECTION_MACRO_CLASSES, Oi as systemEventDeliveryProblems, On as TrackSideSchema, Or as isDrawableSubjectPath, Ot as NcAlarmConfigSchema, P as EMBEDDING_REQUIREMENTS_UNAVAILABLE, Pi as templateVarsFor, Pn as alertEpisodeId, Pr as isRetiredClearKey, Pt as NcConditionsSchema, Q as MOTION_CLOSE_AFTER_MS, Qi as hydrateSchema, Qn as coreBlockIdFromAddonId, Qr as plateGalleryCapability, Qt as OPS_LOG_DEFAULT_LIMIT, R as EXPORT_DENSE_MAX_RANGES, Ri as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, Rn as applyModeDelaysEdits, Rr as isSystemEventSummaryRule, Rt as NcRuleSchema, S as ClearTrackRelationInputSchema, Si as storageOccupancyCapability, Sn as TimelapseRuleInputSchema, Sr as isAlertEpisodeOpen, St as NC_SENSOR_TRANSITION_KINDS, T as DEFAULT_DETAIL_CROP_CONVENTION, Ti as subjectPathSvgPoints, Tn as TrackPuritySchema, Tr as isAudioClassName, Tt as NC_TANK_SYSTEM_EVENT_KINDS, U as LabelAttributionSchema, Ui as errMsg, Un as audioMetricsCapability, Ur as memoizePinnedFfmpeg, Ut as NcSnoozeInputSchema, V as FailureCounters, Vi as isSourceCap, Vn as armRemovalReconcile, Vr as linkedStepOf, Vt as NcScheduleSchema, W as ListTrackRelationsInputSchema, Wi as BaseAddon, Wn as audioModeOf, Wr as ncMediaPolicyError, Wt as NcSnoozeSchema, X as MAX_TRACK_PURITIES, Xi as defineCustomActions, Xn as cloneAlarmSettings, Xr as pickFaceRescoreIntervalMinutes, Xt as NcTemplatePreviewSchema, Y as MAX_BIRTH_LATENCY_PROXY_MS, Yi as customAction, Yn as classifyRetrainReview, Yt as NcTemplatePreviewInputSchema, Z as MAX_TRACK_RELATIONS, Zi as defineRecordKinds, Zn as consumableLowPercentOf, Zr as pipelineAnalyticsCapability, Zt as NcTemplateVarDescriptorSchema, _ as BIRTH_DECISION_DEFAULT_LIMIT, _a as tuple, _i as sceneMonitorCapability, _n as SetTrackPurityInputSchema, _r as faceGalleryCapability, _t as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, a as CLIP_EMBEDDING_STEP_ID, aa as array, ai as resolveLocationMode, an as RetrainOriginSchema, ar as describeSystemEventDeliveryProblem, at as NC_ALARM_NOTIFY_FIELDS, b as BucketAccumulator, ba as EventCategory, bi as servedTemplateVars, bn as TIMELAPSE_DENSE_FLOOR_SEC, br as gifFromMp4Args, bt as NC_GROUPING_MAX_SEC, c as readClusterModelPinStrict, ca as literal, ci as resolveNcMediaImage, cn as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS, ct as NC_AUDIO_CONFIRM_HITS_DEFAULT, d as APPEARANCE_BLOCKS, da as object, di as rootSupplyServes, dn as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, dt as NC_CLIP_PRE_ROLL_DEFAULT_SEC, ea as nodePin, ei as readDeviceStateFrom, er as createAlarmProfile, et as MotionTriggeredBySchema, f as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, fa as partialRecord, fi as ruleAutoTriggersAlarm, fn as SCENE_DEFAULT_QUIET_SECONDS, ft as NC_CLIP_RETENTION_MAX_SEC, g as ArchivedDebugNoteSchema, ga as templateLiteral, gi as ruleKindOf, gn as SceneMonitorSchema, gr as evictionPolicyOfLocation, gt as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, h as AlarmArmModeSchema, ha as string, hi as ruleGroupingApplies, hn as SCENE_RESET_RECAPTURES, hr as eventKindLabel, ht as NC_CONFIRM_DEFAULT_TIMEOUT_MS, ia as _enum, ii as resolveClipSearchMinScore, in as RetrainDismissedSchema, ir as deriveRecordingMode, it as NC_ALARM_DELAY_MAX_SEC, j as DetectionSourceSchema, ji as systemEventSummaryScheduleOf, jn as addonWidgetsSourceCapability, jr as isKnownTemplateVar, jt as NcAlarmSensorsSchema, k as DETECTION_PIPELINE_CAP_NAME, ki as systemEventFamilyProblems, kn as TrackSourceSchema, kr as isEventKindLabelKey, kt as NcAlarmPostTriggeredActionSchema, l as resolveClusterModelPin, la as looseObject, ln as SCENE_DEFAULT_ANCHOR_THRESHOLD, lt as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, m as AUDIO_MACRO_LABELS, ma as strictObject, mi as ruleDeviceCompatCapQueries, mn as SCENE_DIVERGED, mr as ensureAlarmProfiles, mt as NC_CONFIRM_DEFAULT_MAX_IMAGE_PX, na as sleep$1, ni as readTimelapseGeneratedAt, nn as RECORDING_SOURCE_CAMSTACK_ADDON, nr as deriveDetailCropRect, nt as NC_ALARM_DEFAULT_TRIGGERED_DURATION_SEC, o as CLUSTER_MODEL_OWNER_ADDON_ID, oa as boolean, oi as resolveModeDelays, on as RetrainStatusSchema, or as describeSystemEventFamilyProblem, ot as NC_ALARM_SYSTEM_EVENT_KINDS, p as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, pa as record, pi as ruleBelongsToAlarm$1, pn as SCENE_DEFAULT_UNCOVERED_POLICY, pr as encodeVectorBase64, pt as NC_CONDITION_CATALOG, q as MAX_ARCHIVED_DEBUG_NOTES, qi as RECORD_QUERY_MAX, qn as buildViewerLink, qr as occupancyScope, qt as NcSystemEventKindSchema, ra as ZodObject, ri as renameAlarmProfile, rn as RebuildObjectEmbeddingsRefusalSchema, rr as derivePostTriggeredAction, rt as NC_ALARM_DELAY_FIELDS, s as FACE_EMBEDDING_STEP_ID, sa as discriminatedUnion, si as resolveModeNotifyPolicy, sn as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, sr as deviceRemovalHookCapability, st as NC_ALARM_TRIGGERED_MAX_SEC, t as BUILTIN_CLIP_MODELS, ti as readMotionTriggeredBy, tn as RECORDING_EXPORT_MAX_READ_BYTES, tr as deleteAlarmProfile, tt as NC_ALARM_DEFAULT_PROFILE_ID, u as APPEARANCE_BIN_COUNT, ua as number, ui as retrainRevisionConflictMessage, un as SCENE_DEFAULT_CHECK_INTERVAL_SEC, ur as embeddingRefusalOf, ut as NC_CLIP_POST_ROLL_DEFAULT_SEC, v as BaseDevice, va as union, vi as secureActuationFor, vn as SetTrackPurityResultSchema, vr as failureContributionCapability, vt as NC_DEFAULT_SNOOZE_MINUTES, w as CoverStateSchema, wi as subjectPathSampleFromBox, wn as TimelapseRuleSchema, wr as isAppLinkBase, wt as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, x as COCO_TO_MACRO, xi as startMoverUnderClearance, xn as TRACK_RELATIONS_DEFAULT_LIMIT, xr as isAlarmControllableCap, xt as NC_GROUPING_MIN_SEC, y as BirthDecisionRecordSchema, ya as unknown, yi as sensorRaisedOf, yn as SetTrackRelationInputSchema, yr as gifDeriveSpeed, yt as NC_GROUPING_DEFAULT_SEC, z as FIRST_LEVEL_MACRO_CLASSES, zi as canonicalHash, zn as applyNonBlockingEdit, zr as isVectorIndexDimConflict, zt as NcRuleTargetSchema } from "../clip-model-registry-BBzoIoKO.mjs";
1
+ import { $ as MediaFileKindEnum, $i as hydrateSchema, $n as cosineSimilarity$1, $r as projectSubjectPath, $t as OpsLogEntrySchema, A as DeclaredDevices, Ai as systemEventFilterApplies, An as activateAlarmProfile, Ar as isEventOwnerType, At as NcAlarmSensorsInputSchema, B as FULL_IMAGE_BBOX, Bi as canonicalHash, Bn as applySecureOnArmEdit, Br as kebabToCamel, Bt as NcRuleUpdateInputSchema, C as ClearTrackRelationResultSchema, Ci as subKindsOf, Cn as TimelapseRulePatchSchema, Cr as isAlertEpisodeSeverity, Ct as NC_SNOOZE_MAX_MINUTES, D as DEFAULT_TIMELAPSE_PREVIEW_TEXT, Di as syncActiveAlarmProfileSettings, Dn as TrackRelationSchema, Dr as isDetectionMacroClass, Dt as NC_TEMPLATE_VARS, E as DEFAULT_EVENT_COLOR, Ei as summarizeReview, En as TrackRelationKindSchema, Er as isDefaultAlarmProfileId, Et as NC_TAXONOMY, F as EVENT_DENSITY_BATCH_MAX, Fi as vectorDimFromBase64, Fn as alertEpisodeKind, Fr as isRootSuppliableStepId, Ft as NcRuleActionSchema, G as ListTrackRelationsResultSchema, Gi as BaseAddon, Gn as buildEventKindDescriptor, Gr as normalizeNcMediaPolicy, Gt as NcSnoozeSuppressedSchema, H as LABEL_SUPPLY_MACRO, Hi as isSourceCap, Hn as assertTimelapseCadences, Hr as mayWriteToLocation, Ht as NcSharedSequenceSchema, I as EVENT_KIND_BY_CAP, Ii as viewerLinkBaseOf, In as alertEpisodePayload, Ir as isScheduleActive, It as NcRuleInputSchema, J as MAX_BIRTH_DECISION_RECORDS, Ji as RECORD_QUERY_MAX, Jn as calendarEventLocalSpan, Jr as parseAlertEpisodePayload, Jt as NcTaxonomySchema, K as LockStateSchema, Ki as CamProfileSchema, Kn as buildSubjectPath, Kr as notificationRulesCapability, Kt as NcStateSummarySectionIdSchema, L as EVENT_OWNER_TYPES, Li as withSystemEventClasses, Ln as applyBlockingEdit, Lr as isSystemDelivery, Lt as NcRulePatchSchema, M as EMBEDDING_MODEL_FAILED, Mi as systemEventSummarySplitOf, Mn as alarmDeviceSecureOnArm, Mr as isLabelSupplyStepId, Mt as NcAlarmSettingsPatchSchema, N as EMBEDDING_REQUIREMENTS_FAILED, Ni as templateCarriesSummary, Nn as alarmPanelCapability, Nr as isMediaOwnerType, Nt as NcConditionDescriptorSchema, O as DETECTION_MACRO_CLASSES, Oi as systemEventDeliveryProblems, On as TrackSideSchema, Or as isDrawableSubjectPath, Ot as NcAlarmConfigSchema, P as EMBEDDING_REQUIREMENTS_UNAVAILABLE, Pi as templateVarsFor, Pn as alertEpisodeId, Pr as isRetiredClearKey, Pt as NcConditionsSchema, Q as MOTION_CLOSE_AFTER_MS, Qi as defineRecordKinds, Qn as coreBlockIdFromAddonId, Qr as plateGalleryCapability, Qt as OPS_LOG_DEFAULT_LIMIT, R as EXPORT_DENSE_MAX_RANGES, Ri as zoneAnalyticsCapability, Rn as applyModeDelaysEdits, Rr as isSystemEventSummaryRule, Rt as NcRuleSchema, S as ClearTrackRelationInputSchema, Si as storageOccupancyCapability, Sn as TimelapseRuleInputSchema, Sr as isAlertEpisodeOpen, St as NC_SENSOR_TRANSITION_KINDS, T as DEFAULT_DETAIL_CROP_CONVENTION, Ti as subjectPathSvgPoints, Tn as TrackPuritySchema, Tr as isAudioClassName, Tt as NC_TANK_SYSTEM_EVENT_KINDS, U as LabelAttributionSchema, Ui as DeviceType, Un as audioMetricsCapability, Ur as memoizePinnedFfmpeg, Ut as NcSnoozeInputSchema, V as FailureCounters, Vi as evaluateSensorEdge, Vn as armRemovalReconcile, Vr as linkedStepOf, Vt as NcScheduleSchema, W as ListTrackRelationsInputSchema, Wi as errMsg, Wn as audioModeOf, Wr as ncMediaPolicyError, Wt as NcSnoozeSchema, X as MAX_TRACK_PURITIES, Xi as customAction, Xn as cloneAlarmSettings, Xr as pickFaceRescoreIntervalMinutes, Xt as NcTemplatePreviewSchema, Y as MAX_BIRTH_LATENCY_PROXY_MS, Yi as createEvent, Yn as classifyRetrainReview, Yt as NcTemplatePreviewInputSchema, Z as MAX_TRACK_RELATIONS, Zi as defineCustomActions, Zn as consumableLowPercentOf, Zr as pipelineAnalyticsCapability, Zt as NcTemplateVarDescriptorSchema, _ as BIRTH_DECISION_DEFAULT_LIMIT, _a as templateLiteral, _i as sceneMonitorCapability, _n as SetTrackPurityInputSchema, _r as faceGalleryCapability, _t as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, a as CLIP_EMBEDDING_STEP_ID, aa as _enum, ai as resolveLocationMode, an as RetrainOriginSchema, ar as describeSystemEventDeliveryProblem, at as NC_ALARM_NOTIFY_FIELDS, b as BucketAccumulator, ba as unknown, bi as servedTemplateVars, bn as TIMELAPSE_DENSE_FLOOR_SEC, br as gifFromMp4Args, bt as NC_GROUPING_MAX_SEC, c as readClusterModelPinStrict, ca as discriminatedUnion, ci as resolveNcMediaImage, cn as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS, ct as NC_AUDIO_CONFIRM_HITS_DEFAULT, d as APPEARANCE_BLOCKS, da as number, di as rootSupplyServes, dn as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, dt as NC_CLIP_PRE_ROLL_DEFAULT_SEC, ea as isDeviceScopedCap, ei as readDeviceStateFrom, er as createAlarmProfile, et as MotionTriggeredBySchema, f as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, fa as object, fi as ruleAutoTriggersAlarm, fn as SCENE_DEFAULT_QUIET_SECONDS, ft as NC_CLIP_RETENTION_MAX_SEC, g as ArchivedDebugNoteSchema, ga as string, gi as ruleKindOf, gn as SceneMonitorSchema, gr as evictionPolicyOfLocation, gt as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, h as AlarmArmModeSchema, ha as strictObject, hi as ruleGroupingApplies, hn as SCENE_RESET_RECAPTURES, hr as eventKindLabel, ht as NC_CONFIRM_DEFAULT_TIMEOUT_MS, ia as ZodObject, ii as resolveClipSearchMinScore, in as RetrainDismissedSchema, ir as deriveRecordingMode, it as NC_ALARM_DELAY_MAX_SEC, j as DetectionSourceSchema, ji as systemEventSummaryScheduleOf, jn as addonWidgetsSourceCapability, jr as isKnownTemplateVar, jt as NcAlarmSensorsSchema, k as DETECTION_PIPELINE_CAP_NAME, ki as systemEventFamilyProblems, kn as TrackSourceSchema, kr as isEventKindLabelKey, kt as NcAlarmPostTriggeredActionSchema, l as resolveClusterModelPin, la as literal, ln as SCENE_DEFAULT_ANCHOR_THRESHOLD, lt as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, m as AUDIO_MACRO_LABELS, ma as record, mi as ruleDeviceCompatCapQueries, mn as SCENE_DIVERGED, mr as ensureAlarmProfiles, mt as NC_CONFIRM_DEFAULT_MAX_IMAGE_PX, ni as readTimelapseGeneratedAt, nn as RECORDING_SOURCE_CAMSTACK_ADDON, nr as deriveDetailCropRect, nt as NC_ALARM_DEFAULT_TRIGGERED_DURATION_SEC, o as CLUSTER_MODEL_OWNER_ADDON_ID, oa as array, oi as resolveModeDelays, on as RetrainStatusSchema, or as describeSystemEventFamilyProblem, ot as NC_ALARM_SYSTEM_EVENT_KINDS, p as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, pa as partialRecord, pi as ruleBelongsToAlarm$1, pn as SCENE_DEFAULT_UNCOVERED_POLICY, pr as encodeVectorBase64, pt as NC_CONDITION_CATALOG, q as MAX_ARCHIVED_DEBUG_NOTES, qi as RECORD_MAX_DATA_BYTES, qn as buildViewerLink, qr as occupancyScope, qt as NcSystemEventKindSchema, ra as sleep$1, ri as renameAlarmProfile, rn as RebuildObjectEmbeddingsRefusalSchema, rr as derivePostTriggeredAction, rt as NC_ALARM_DELAY_FIELDS, s as FACE_EMBEDDING_STEP_ID, sa as boolean, si as resolveModeNotifyPolicy, sn as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, sr as deviceRemovalHookCapability, st as NC_ALARM_TRIGGERED_MAX_SEC, t as BUILTIN_CLIP_MODELS, ta as nodePin, ti as readMotionTriggeredBy, tn as RECORDING_EXPORT_MAX_READ_BYTES, tr as deleteAlarmProfile, tt as NC_ALARM_DEFAULT_PROFILE_ID, u as APPEARANCE_BIN_COUNT, ua as looseObject, ui as retrainRevisionConflictMessage, un as SCENE_DEFAULT_CHECK_INTERVAL_SEC, ur as embeddingRefusalOf, ut as NC_CLIP_POST_ROLL_DEFAULT_SEC, v as BaseDevice, va as tuple, vi as secureActuationFor, vn as SetTrackPurityResultSchema, vr as failureContributionCapability, vt as NC_DEFAULT_SNOOZE_MINUTES, w as CoverStateSchema, wi as subjectPathSampleFromBox, wn as TimelapseRuleSchema, wr as isAppLinkBase, wt as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, x as COCO_TO_MACRO, xa as EventCategory, xi as startMoverUnderClearance, xn as TRACK_RELATIONS_DEFAULT_LIMIT, xr as isAlarmControllableCap, xt as NC_GROUPING_MIN_SEC, y as BirthDecisionRecordSchema, ya as union, yi as sensorRaisedOf, yn as SetTrackRelationInputSchema, yr as gifDeriveSpeed, yt as NC_GROUPING_DEFAULT_SEC, z as FIRST_LEVEL_MACRO_CLASSES, zi as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, zn as applyNonBlockingEdit, zr as isVectorIndexDimConflict, zt as NcRuleTargetSchema } from "../clip-model-registry-EDTOCKN9.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs$1 from "node:fs";
4
4
  import { createWriteStream, promises } from "node:fs";
@@ -5394,15 +5394,28 @@ async function notificationLinkEndpoints(api, logger) {
5394
5394
  */
5395
5395
  var NC_TRACKS_AT_INSTANT_LIMIT = 25;
5396
5396
  /**
5397
- * Every source track with a position within `toleranceMs` of `timestamp`, its
5398
- * nearest box normalised, deduplicated by track id (a live copy and a
5399
- * persisted copy of one track are one track). Pure.
5397
+ * How far outside its own life a track may still be drawn: one detection tick.
5398
+ *
5399
+ * The box TOLERANCE (`toleranceMs`, 750 ms) is about how sparse a trail is —
5400
+ * a live track keeps a position every few frames — and it must never stretch
5401
+ * a LIFE. A track born after the frame was not on it: on 2026-10-22 the
5402
+ * tracker split a dog on camera 615 and the second piece, born 676 ms after
5403
+ * the key frame, was drawn at its first box as a second box on the dog. One
5404
+ * tick (~110–150 ms at that camera's detection rate, rounded up) absorbs the
5405
+ * gap between a frame and the detection that confirmed it, and nothing more.
5406
+ */
5407
+ var NC_TRACK_LIFE_SLACK_MS = 200;
5408
+ /**
5409
+ * Every source track ALIVE at `timestamp` (± {@link NC_TRACK_LIFE_SLACK_MS})
5410
+ * with a position within `toleranceMs` of it, its nearest box normalised,
5411
+ * deduplicated by track id (a live copy and a persisted copy of one track are
5412
+ * one track). Pure.
5400
5413
  */
5401
5414
  function tracksAtInstant(sources, timestamp, toleranceMs, fallbackDims) {
5402
5415
  const out = /* @__PURE__ */ new Map();
5403
5416
  for (const track of sources) {
5404
5417
  if (out.has(track.trackId)) continue;
5405
- if (track.firstSeen > timestamp + toleranceMs || track.lastSeen < timestamp - toleranceMs) continue;
5418
+ if (track.firstSeen > timestamp + NC_TRACK_LIFE_SLACK_MS || track.lastSeen < timestamp - NC_TRACK_LIFE_SLACK_MS) continue;
5406
5419
  const w = track.frameWidth ?? fallbackDims?.w ?? 0;
5407
5420
  const h = track.frameHeight ?? fallbackDims?.h ?? 0;
5408
5421
  if (w <= 0 || h <= 0) continue;
@@ -20443,8 +20456,8 @@ var FACE_REF_COLUMNS = ["deviceId"];
20443
20456
  * D312) a predicate that silently matched nothing. The faces ledger therefore derives a
20444
20457
  * boolean `recognized` from every write that touches `recognizedIdentityId`
20445
20458
  * (`face-recognized.ts`), and the views are plain ranges over the `recognized_ts` /
20446
- * `assigned_recognized_ts` indexes. A row written before the field existed lacks it until
20447
- * `scripts/hub/backfill-faces-recognized.ts` has run.
20459
+ * `assigned_recognized_ts` indexes. Rows written before the field existed were
20460
+ * backfilled offline on the operator hubs on 2026-10-22 (the one-shot script is deleted).
20448
20461
  *
20449
20462
  * `assigned` is `BOOLEAN NOT NULL`; the backend serialises `false` to `0` at
20450
20463
  * the bind (`serializeColumnValue`) and decodes `0` back to `false` on read, so
@@ -28489,6 +28502,33 @@ function incomingFromSystemEvent(event, lookup) {
28489
28502
  ...nodeId !== void 0 ? { nodeId } : {}
28490
28503
  });
28491
28504
  }
28505
+ case "notification.rule-broken":
28506
+ case "notification.rule-repaired": {
28507
+ const ruleId = stringField(data, "ruleId");
28508
+ if (ruleId === void 0) return null;
28509
+ const broken = event.category === "notification.rule-broken";
28510
+ const kind = broken ? "rule-broken" : "rule-repaired";
28511
+ const ruleName = stringField(data, "ruleName") ?? ruleId;
28512
+ const reason = stringField(data, "reason") ?? "unknown";
28513
+ const ids = data["offendingIds"];
28514
+ const offendingIds = Array.isArray(ids) ? ids.filter((id) => typeof id === "string").join(", ") : "";
28515
+ return systemSubject({
28516
+ facts,
28517
+ kind,
28518
+ subject: `rule-health:${ruleId}`,
28519
+ titleKey: `system.${kind}.title`,
28520
+ bodyKey: broken ? `system.rule-broken.body.${reason}` : "system.rule-repaired.body",
28521
+ textParams: broken ? {
28522
+ ruleId,
28523
+ ruleName,
28524
+ brokenReason: reason,
28525
+ offendingIds
28526
+ } : {
28527
+ ruleId,
28528
+ ruleName
28529
+ }
28530
+ });
28531
+ }
28492
28532
  case "update.available": return incomingFromUpdateAvailable(facts, data);
28493
28533
  case "addon.updated": {
28494
28534
  const update = parseAddonUpdateComplete(data);
@@ -30494,8 +30534,8 @@ var en_default = {
30494
30534
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
30495
30535
  "occupancy.op.occupied": "occupied",
30496
30536
  "occupancy.op.free": "free",
30497
- "system.device-online.title": "Online — {{deviceName}}",
30498
- "system.device-online.body": "Device {{deviceName}} is online",
30537
+ "system.device-online.title": "Back online — {{deviceName}}",
30538
+ "system.device-online.body": "Device {{deviceName}} is back online",
30499
30539
  "system.device-offline.title": "Offline — {{deviceName}}",
30500
30540
  "system.device-offline.body": "Device {{deviceName}} is offline",
30501
30541
  "system.device-disabled.title": "Switched off — {{deviceName}}",
@@ -30508,14 +30548,14 @@ var en_default = {
30508
30548
  "system.device-battery-normal.title": "Battery back to normal — {{deviceName}}",
30509
30549
  "system.device-battery-normal.body": "{{deviceName}} is back to {{percentage}}% battery",
30510
30550
  "system.device-battery-normal.body.binary": "{{deviceName}} no longer reports a low battery",
30511
- "system.stream-online.title": "Stream online — {{deviceName}}",
30512
- "system.stream-online.body": "Stream for camera {{deviceName}} is online",
30513
- "system.stream-online.body.profiled": "{{profile}} stream for camera {{deviceName}} is online",
30551
+ "system.stream-online.title": "Stream back online — {{deviceName}}",
30552
+ "system.stream-online.body": "Stream for camera {{deviceName}} is back online",
30553
+ "system.stream-online.body.profiled": "{{profile}} stream for camera {{deviceName}} is back online",
30514
30554
  "system.stream-offline.title": "Stream offline — {{deviceName}}",
30515
30555
  "system.stream-offline.body": "Stream for camera {{deviceName}} is offline",
30516
30556
  "system.stream-offline.body.profiled": "{{profile}} stream for camera {{deviceName}} is offline",
30517
- "system.node-online.title": "Node online — {{nodeName}}",
30518
- "system.node-online.body.agent": "Agent {{nodeName}} is online",
30557
+ "system.node-online.title": "Node back online — {{nodeName}}",
30558
+ "system.node-online.body.agent": "Agent {{nodeName}} is back online",
30519
30559
  "system.node-online.body.agent.after": "Agent {{nodeName}} is back online after {{offlineFor}} offline",
30520
30560
  "system.node-offline.title": "Node offline — {{nodeName}}",
30521
30561
  "system.node-offline.body.agent": "Agent {{nodeName}} is offline",
@@ -30535,7 +30575,7 @@ var en_default = {
30535
30575
  "one": "Camera {{deviceName}} has not written a recording segment for {{minutes}} minute",
30536
30576
  "other": "Camera {{deviceName}} has not written a recording segment for {{minutes}} minutes"
30537
30577
  },
30538
- "system.recording-healthy.title": "Recording healthy — {{deviceName}}",
30578
+ "system.recording-healthy.title": "Recording again — {{deviceName}}",
30539
30579
  "system.recording-healthy.body": "Camera {{deviceName}} is writing recording segments again",
30540
30580
  "system.recording-degraded.title": "Recording degraded — {{deviceName}}",
30541
30581
  "system.recording-degraded.body": "Camera {{deviceName}} exhausted its writer restart budget ({{message}})",
@@ -30796,7 +30836,13 @@ var en_default = {
30796
30836
  "calendar-events.line.timed": "• {{when}} {{title}}{{where}}",
30797
30837
  "calendar-events.line.allDay": "• All day {{title}}{{where}}",
30798
30838
  "calendar-events.where": " @ {{location}}",
30799
- "calendar-events.more": "…and {{count}} more"
30839
+ "calendar-events.more": "…and {{count}} more",
30840
+ "system.rule-broken.title": "Notification rule broken — {{ruleName}}",
30841
+ "system.rule-broken.body.unparseable": "The rule \"{{ruleName}}\" cannot be loaded and sends nothing. Open it and save it again.",
30842
+ "system.rule-broken.body.device-deleted": "The rule \"{{ruleName}}\" still names a deleted device ({{offendingIds}}) and was switched off. Pick the device again.",
30843
+ "system.rule-broken.body.target-deleted": "The rule \"{{ruleName}}\" still sends to a deleted destination ({{offendingIds}}) and was switched off. Pick a destination again.",
30844
+ "system.rule-repaired.title": "Notification rule works again — {{ruleName}}",
30845
+ "system.rule-repaired.body": "The rule \"{{ruleName}}\" loads and names nothing deleted any more."
30800
30846
  };
30801
30847
  //#endregion
30802
30848
  //#region src/notification-center/texts/it.json
@@ -30881,8 +30927,8 @@ var it_default = {
30881
30927
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
30882
30928
  "occupancy.op.occupied": "occupata",
30883
30929
  "occupancy.op.free": "libera",
30884
- "system.device-online.title": "Online — {{deviceName}}",
30885
- "system.device-online.body": "Il dispositivo {{deviceName}} è online",
30930
+ "system.device-online.title": "Di nuovo online — {{deviceName}}",
30931
+ "system.device-online.body": "Il dispositivo {{deviceName}} è di nuovo online",
30886
30932
  "system.device-offline.title": "Offline — {{deviceName}}",
30887
30933
  "system.device-offline.body": "Il dispositivo {{deviceName}} è offline",
30888
30934
  "system.device-disabled.title": "Spento — {{deviceName}}",
@@ -30895,14 +30941,14 @@ var it_default = {
30895
30941
  "system.device-battery-normal.title": "Batteria rientrata — {{deviceName}}",
30896
30942
  "system.device-battery-normal.body": "{{deviceName}} è risalito al {{percentage}}% di batteria",
30897
30943
  "system.device-battery-normal.body.binary": "{{deviceName}} non segnala più la batteria scarica",
30898
- "system.stream-online.title": "Stream online — {{deviceName}}",
30899
- "system.stream-online.body": "Lo stream della camera {{deviceName}} è online",
30900
- "system.stream-online.body.profiled": "Lo stream {{profile}} della camera {{deviceName}} è online",
30944
+ "system.stream-online.title": "Stream di nuovo online — {{deviceName}}",
30945
+ "system.stream-online.body": "Lo stream della camera {{deviceName}} è di nuovo online",
30946
+ "system.stream-online.body.profiled": "Lo stream {{profile}} della camera {{deviceName}} è di nuovo online",
30901
30947
  "system.stream-offline.title": "Stream offline — {{deviceName}}",
30902
30948
  "system.stream-offline.body": "Lo stream della camera {{deviceName}} è offline",
30903
30949
  "system.stream-offline.body.profiled": "Lo stream {{profile}} della camera {{deviceName}} è offline",
30904
- "system.node-online.title": "Nodo online — {{nodeName}}",
30905
- "system.node-online.body.agent": "L'agent {{nodeName}} è online",
30950
+ "system.node-online.title": "Nodo di nuovo online — {{nodeName}}",
30951
+ "system.node-online.body.agent": "L'agent {{nodeName}} è di nuovo online",
30906
30952
  "system.node-online.body.agent.after": "L'agent {{nodeName}} è di nuovo online dopo {{offlineFor}} offline",
30907
30953
  "system.node-offline.title": "Nodo offline — {{nodeName}}",
30908
30954
  "system.node-offline.body.agent": "L'agent {{nodeName}} è offline",
@@ -30917,7 +30963,7 @@ var it_default = {
30917
30963
  "one": "La camera {{deviceName}} non scrive un segmento da {{minutes}} minuto",
30918
30964
  "other": "La camera {{deviceName}} non scrive un segmento da {{minutes}} minuti"
30919
30965
  },
30920
- "system.recording-healthy.title": "Registrazione ok — {{deviceName}}",
30966
+ "system.recording-healthy.title": "Registra di nuovo — {{deviceName}}",
30921
30967
  "system.recording-healthy.body": "La camera {{deviceName}} scrive di nuovo i segmenti",
30922
30968
  "system.recording-degraded.title": "Registrazione degradata — {{deviceName}}",
30923
30969
  "system.recording-degraded.body": "La camera {{deviceName}} ha esaurito i riavvii del writer ({{message}})",
@@ -31183,7 +31229,13 @@ var it_default = {
31183
31229
  "calendar-events.line.timed": "• {{when}} {{title}}{{where}}",
31184
31230
  "calendar-events.line.allDay": "• Tutto il giorno {{title}}{{where}}",
31185
31231
  "calendar-events.where": " @ {{location}}",
31186
- "calendar-events.more": "…e altri {{count}}"
31232
+ "calendar-events.more": "…e altri {{count}}",
31233
+ "system.rule-broken.title": "Regola di notifica rotta — {{ruleName}}",
31234
+ "system.rule-broken.body.unparseable": "La regola \"{{ruleName}}\" non si carica e non invia nulla. Aprila e salvala di nuovo.",
31235
+ "system.rule-broken.body.device-deleted": "La regola \"{{ruleName}}\" usa ancora un dispositivo eliminato ({{offendingIds}}) ed è stata disattivata. Scegli di nuovo il dispositivo.",
31236
+ "system.rule-broken.body.target-deleted": "La regola \"{{ruleName}}\" invia ancora a una destinazione eliminata ({{offendingIds}}) ed è stata disattivata. Scegli di nuovo una destinazione.",
31237
+ "system.rule-repaired.title": "Regola di notifica di nuovo funzionante — {{ruleName}}",
31238
+ "system.rule-repaired.body": "La regola \"{{ruleName}}\" si carica e non usa più nulla di eliminato."
31187
31239
  };
31188
31240
  //#endregion
31189
31241
  //#region src/notification-center/text-catalog.ts
@@ -33454,6 +33506,10 @@ function systemEventKeys(kind) {
33454
33506
  titleKey: `system.${kind}.title`,
33455
33507
  bodyKey: `system.${kind}.body.cleanWater`
33456
33508
  };
33509
+ if (kind === "rule-broken") return {
33510
+ titleKey: `system.${kind}.title`,
33511
+ bodyKey: `system.${kind}.body.device-deleted`
33512
+ };
33457
33513
  return {
33458
33514
  titleKey: `system.${kind}.title`,
33459
33515
  bodyKey: `system.${kind}.body`
@@ -39694,6 +39750,99 @@ var NcRecordedCutMemo = class {
39694
39750
  }
39695
39751
  };
39696
39752
  //#endregion
39753
+ //#region src/notification-center/notification-label-layout.ts
39754
+ /**
39755
+ * Where each box's caption goes on a notification picture — pure geometry,
39756
+ * rects in, placements out (D897 follow-up, 2026-10-22).
39757
+ *
39758
+ * A caption drawn INSIDE its box used to cover the subject whole on a small
39759
+ * box, and a caption next to a crowd landed on a neighbour's box. The rule:
39760
+ *
39761
+ * 1. Try, in order: OUTSIDE above the box (inside the picture), OUTSIDE below
39762
+ * it, then INSIDE its top edge — the last only when the box is big enough
39763
+ * that the plate is a thin band of it: plate height ≤
39764
+ * {@link NC_LABEL_INSIDE_MAX_HEIGHT_FRACTION} of the box height and plate
39765
+ * width ≤ the box width. A fifth of the height leaves the head and torso of
39766
+ * an upright person, or most of a vehicle, visible; anything taller is the
39767
+ * caption hiding what it names.
39768
+ * 2. A candidate is refused when its plate intersects ANY other box drawn on
39769
+ * the picture, or a plate already placed.
39770
+ * 3. The subject is placed first; the other tracks follow by score, highest
39771
+ * first, so a crowd never costs the subject its caption.
39772
+ * 4. The font never shrinks to make a plate fit: no fitting candidate means
39773
+ * no caption for that box. A box without its caption still carries its
39774
+ * outline and colour.
39775
+ *
39776
+ * Coordinates are the picture's own pixels, whatever it is (a scene or a crop).
39777
+ */
39778
+ /** The inside placement is allowed only when the plate is at most this fraction of the box height. */
39779
+ var NC_LABEL_INSIDE_MAX_HEIGHT_FRACTION = .2;
39780
+ /** A caption is never drawn smaller than this, in output pixels. */
39781
+ var NC_LABEL_MIN_FONT_PX = 14;
39782
+ /** The dark plate a caption sits on, for this text at this font size. */
39783
+ function labelPlateSize(label, fontSize) {
39784
+ return {
39785
+ w: Math.round(label.length * fontSize * .62 + fontSize * .8),
39786
+ h: Math.round(fontSize * 1.35)
39787
+ };
39788
+ }
39789
+ /** Positive-area overlap; rectangles that only touch do not intersect. */
39790
+ function intersects(a, b) {
39791
+ 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;
39792
+ }
39793
+ function captionOrder(boxes) {
39794
+ const rank = (b) => b.role === "subject" ? 0 : 1;
39795
+ return boxes.map((_, i) => i).sort((i, j) => {
39796
+ const a = boxes[i];
39797
+ const b = boxes[j];
39798
+ if (a === void 0 || b === void 0) return 0;
39799
+ return rank(a) - rank(b) || (b.score ?? 0) - (a.score ?? 0) || i - j;
39800
+ });
39801
+ }
39802
+ function candidates(box, plate, width, height) {
39803
+ if (plate.w > width) return [];
39804
+ const x = Math.min(box.x, Math.max(0, width - plate.w));
39805
+ const out = [];
39806
+ if (box.y - plate.h >= 0) out.push({
39807
+ x,
39808
+ y: box.y - plate.h,
39809
+ ...plate,
39810
+ where: "above"
39811
+ });
39812
+ if (box.y + box.h + plate.h <= height) out.push({
39813
+ x,
39814
+ y: box.y + box.h,
39815
+ ...plate,
39816
+ where: "below"
39817
+ });
39818
+ if (plate.h <= box.h * NC_LABEL_INSIDE_MAX_HEIGHT_FRACTION && plate.w <= box.w) out.push({
39819
+ x,
39820
+ y: box.y,
39821
+ ...plate,
39822
+ where: "inside"
39823
+ });
39824
+ return out;
39825
+ }
39826
+ /** Place every caption by the rule above. */
39827
+ function layoutBoxLabels(input) {
39828
+ const { width, height, boxes } = input;
39829
+ const fontSize = Math.max(NC_LABEL_MIN_FONT_PX, input.fontSize);
39830
+ const placements = /* @__PURE__ */ new Map();
39831
+ let dropped = 0;
39832
+ for (const i of captionOrder(boxes)) {
39833
+ const box = boxes[i];
39834
+ if (box === void 0 || box.label.length === 0) continue;
39835
+ 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)));
39836
+ if (chosen === void 0) dropped += 1;
39837
+ else placements.set(i, chosen);
39838
+ }
39839
+ return {
39840
+ placements: boxes.map((_, i) => placements.get(i) ?? null),
39841
+ placed: placements.size,
39842
+ dropped
39843
+ };
39844
+ }
39845
+ //#endregion
39697
39846
  //#region src/notification-center/trigger-zone-still.ts
39698
39847
  /**
39699
39848
  * How far a candidate's instant may sit from the nearest trail point before the
@@ -39816,6 +39965,15 @@ var NC_IMAGE_OTHER_COLOR = "#f2f2f2";
39816
39965
  var NC_IMAGE_ZONE_COLOR = "#29b6f6";
39817
39966
  /** Two tracks whose boxes overlap this much at one instant are the same subject (a fused id). */
39818
39967
  var SAME_SUBJECT_IOU = .8;
39968
+ /**
39969
+ * A box of the SUBJECT'S CLASS overlapping the subject this much at one
39970
+ * instant is the subject itself — an unfused piece L2 has not joined yet
39971
+ * (D683 fuses 10–20 s later; a push leaves ~6 s after the trigger). The
39972
+ * detector's per-class NMS (`NMS_IOU_DEFAULT` 0.45) keeps one of two
39973
+ * same-class boxes above 0.45 in a frame, so two REAL subjects of one class
39974
+ * cannot overlap past it; 0.5 leaves a margin over that.
39975
+ */
39976
+ var NC_IMAGE_SAME_CLASS_SUBJECT_IOU = .5;
39819
39977
  var JPEG_QUALITY = 85;
39820
39978
  /** The caption of a box: class (or label) and confidence — "dog 87%". Language-free. */
39821
39979
  function imageLabelText(name, confidence) {
@@ -39848,15 +40006,21 @@ function toPixels(box, width, height) {
39848
40006
  h: Math.max(1, Math.min(Math.round(box.h * height), height - y))
39849
40007
  };
39850
40008
  }
40009
+ /** {@link notificationOverlay}'s SVG alone. */
40010
+ function notificationOverlaySvg(input) {
40011
+ return notificationOverlay(input).svg;
40012
+ }
39851
40013
  /**
39852
- * The SVG drawn over a scene picture of `width` × `height`. The subject is
39853
- * drawn LAST so a crowd never hides it. Captions sit on a dark plate above
39854
- * the box (inside it when the box touches the top edge), white text.
40014
+ * The SVG drawn over a picture of `width` × `height`. The subject is drawn
40015
+ * LAST so a crowd never hides it. Captions sit on a dark plate, white text,
40016
+ * where `layoutBoxLabels` puts them — outside above, outside below, inside a
40017
+ * box only when it covers a thin band of it, never on another box or caption,
40018
+ * and nowhere at all rather than over the subject.
39855
40019
  */
39856
- function notificationOverlaySvg(input) {
40020
+ function notificationOverlay(input) {
39857
40021
  const { width, height } = input;
39858
40022
  const strokes = overlayStrokes(width, height);
39859
- const fontSize = Math.max(14, Math.round(height / 36));
40023
+ const fontSize = Math.max(NC_LABEL_MIN_FONT_PX, Math.round(height / 36));
39860
40024
  const parts = [];
39861
40025
  for (const zone of input.zones) {
39862
40026
  if (zone.length < 3) continue;
@@ -39868,19 +40032,29 @@ function notificationOverlaySvg(input) {
39868
40032
  parts.push(`<polyline data-role="subject-path" points="${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"/>`);
39869
40033
  }
39870
40034
  const ordered = [...input.boxes.filter((b) => b.role === "other"), ...input.boxes.filter((b) => b.role === "subject")];
39871
- for (const box of ordered) {
40035
+ const layout = layoutBoxLabels({
40036
+ width,
40037
+ height,
40038
+ fontSize,
40039
+ boxes: ordered
40040
+ });
40041
+ const plates = [];
40042
+ ordered.forEach((box, i) => {
39872
40043
  const subject = box.role === "subject";
39873
40044
  const color = subject ? NC_IMAGE_SUBJECT_COLOR : NC_IMAGE_OTHER_COLOR;
39874
40045
  const stroke = subject ? strokes.subject : strokes.other;
39875
40046
  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)}"/>`);
39876
- if (box.label.length === 0) continue;
39877
- const plateH = Math.round(fontSize * 1.35);
39878
- const plateW = Math.round(box.label.length * fontSize * .62 + fontSize * .8);
39879
- const plateY = box.y - plateH >= 0 ? box.y - plateH : box.y;
39880
- const plateX = Math.min(box.x, Math.max(0, width - plateW));
39881
- 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>`);
39882
- }
39883
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${String(width)}" height="${String(height)}">${parts.join("")}</svg>`;
40047
+ const plate = layout.placements[i];
40048
+ if (plate === null || plate === void 0) return;
40049
+ 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>`);
40050
+ });
40051
+ return {
40052
+ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${String(width)}" height="${String(height)}">${[...parts, ...plates].join("")}</svg>`,
40053
+ labels: {
40054
+ placed: layout.placed,
40055
+ dropped: layout.dropped
40056
+ }
40057
+ };
39884
40058
  }
39885
40059
  /** The pixel rectangle a `crop` cuts from a `width` × `height` frame, or null when too small. */
39886
40060
  function subjectCropRect(box, width, height) {
@@ -39910,11 +40084,18 @@ function iou$4(a, b) {
39910
40084
  }
39911
40085
  /**
39912
40086
  * The tracks to draw thin: every track at the instant except the subject —
39913
- * the same id, or a box that is the subject's (a fused id reads under its
39914
- * survivor's name).
39915
- */
39916
- function otherTracks(tracks, subjectTrackId, subjectBox) {
39917
- return tracks.filter((t) => t.trackId !== subjectTrackId && (subjectBox === null || iou$4(t.bbox, subjectBox) < SAME_SUBJECT_IOU));
40087
+ * the same id, a box that is the subject's (a fused id reads under its
40088
+ * survivor's name), or a box of the subject's class over it past what NMS
40089
+ * lets two real subjects share (an unfused piece of the same subject).
40090
+ */
40091
+ function otherTracks(tracks, subjectTrackId, subjectBox, subjectClassName) {
40092
+ return tracks.filter((t) => {
40093
+ if (t.trackId === subjectTrackId) return false;
40094
+ if (subjectBox === null) return true;
40095
+ const overlap = iou$4(t.bbox, subjectBox);
40096
+ if (overlap >= SAME_SUBJECT_IOU) return false;
40097
+ return !(t.className === subjectClassName && overlap >= NC_IMAGE_SAME_CLASS_SUBJECT_IOU);
40098
+ });
39918
40099
  }
39919
40100
  /**
39920
40101
  * What picture a ROW asks for. A row written since D897 carries `mediaImage`;
@@ -39931,9 +40112,15 @@ function payloadImageMode(payload) {
39931
40112
  if (payload.media === "keyFrame") return "clean";
39932
40113
  return "crop";
39933
40114
  }
39934
- /** The zone ids a row's picture outlines: the zones that fired it, when it asked. */
40115
+ /**
40116
+ * The zone ids a row's picture outlines: the zone(s) THIS event triggered,
40117
+ * when it asked — never every zone the rule lists (a dog in Uscio drew Aiuola
40118
+ * too). A row that asked but froze no `matchedZoneIds` (written before the
40119
+ * field) draws none: an outline of a zone the subject never entered is a
40120
+ * picture that lies about its own reason.
40121
+ */
39935
40122
  function payloadZoneOverlayIds(payload) {
39936
- if (payload.mediaShowTriggerZone === true) return payload.triggerZoneIds ?? [];
40123
+ if (payload.mediaShowTriggerZone === true) return payload.matchedZoneIds ?? [];
39937
40124
  if (payload.mediaImage === void 0) return payload.mediaZoneIds ?? [];
39938
40125
  return [];
39939
40126
  }
@@ -40022,7 +40209,8 @@ async function renderNotificationImage(req) {
40022
40209
  const boxes = req.mode === "scene-boxes" ? [...req.others.map((o) => ({
40023
40210
  ...toPixels(o, outW, outH),
40024
40211
  role: "other",
40025
- label: o.label
40212
+ label: o.label,
40213
+ ...o.confidence !== void 0 ? { score: o.confidence } : {}
40026
40214
  })), ...req.subject !== null ? [{
40027
40215
  ...toPixels(req.subject, outW, outH),
40028
40216
  role: "subject",
@@ -40040,16 +40228,18 @@ async function renderNotificationImage(req) {
40040
40228
  height: outH,
40041
40229
  fit: "fill"
40042
40230
  });
40231
+ let labels;
40043
40232
  if (boxes.length > 0 || zones.length > 0 || paths.length > 0) {
40044
- const svg = notificationOverlaySvg({
40233
+ const overlay = notificationOverlay({
40045
40234
  width: outW,
40046
40235
  height: outH,
40047
40236
  boxes,
40048
40237
  zones,
40049
40238
  paths
40050
40239
  });
40240
+ if (boxes.length > 0) labels = overlay.labels;
40051
40241
  pipeline = pipeline.composite([{
40052
- input: Buffer.from(svg),
40242
+ input: Buffer.from(overlay.svg),
40053
40243
  top: 0,
40054
40244
  left: 0
40055
40245
  }]);
@@ -40059,7 +40249,8 @@ async function renderNotificationImage(req) {
40059
40249
  return {
40060
40250
  bytes: toArrayBufferBytes(out),
40061
40251
  name: `${rendered}.jpg`,
40062
- rendered
40252
+ rendered,
40253
+ ...labels !== void 0 ? { labels } : {}
40063
40254
  };
40064
40255
  }
40065
40256
  var NcNotificationImageStage = class {
@@ -40133,10 +40324,19 @@ var NcNotificationImageStage = class {
40133
40324
  ...subjectBox,
40134
40325
  label: imageLabelText(payload.subject.label ?? payload.subject.className, payload.subject.confidence)
40135
40326
  } : null;
40136
- const others = mode === "scene-boxes" ? otherTracks(await this.tracksAt(camera, source.timestamp), subjectTrackId, subjectBox).map((t) => ({
40327
+ const others = mode === "scene-boxes" ? otherTracks(await this.tracksAt(camera, source.timestamp), subjectTrackId, subjectBox, payload.subject.className).map((t) => ({
40137
40328
  ...t.bbox,
40138
- label: imageLabelText(t.label ?? t.className, t.confidence)
40329
+ label: imageLabelText(t.label ?? t.className, t.confidence),
40330
+ ...t.confidence !== void 0 ? { confidence: t.confidence } : {}
40139
40331
  })) : [];
40332
+ 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", {
40333
+ tags: { deviceId: camera },
40334
+ meta: {
40335
+ ruleId: entry.ruleId,
40336
+ recordId: entry.recordId,
40337
+ ruleZoneIds: (payload.triggerZoneIds ?? []).join(",")
40338
+ }
40339
+ });
40140
40340
  const zones = mode === "crop" || zoneIds.length === 0 ? [] : await this.zonePolygons(camera, zoneIds);
40141
40341
  try {
40142
40342
  const image = await this.render({
@@ -40149,6 +40349,7 @@ var NcNotificationImageStage = class {
40149
40349
  });
40150
40350
  const reason = image.rendered === mode ? void 0 : mode === "crop" ? "no-subject-box" : "no-tracks-at-instant";
40151
40351
  if (reason !== void 0) this.reportFallback(input, mode, reason);
40352
+ if (image.labels !== void 0) this.reportLabels(input, image.labels);
40152
40353
  return reason !== void 0 ? {
40153
40354
  ...image,
40154
40355
  reason
@@ -40197,6 +40398,19 @@ var NcNotificationImageStage = class {
40197
40398
  }
40198
40399
  });
40199
40400
  }
40401
+ /** ONE line per rendered picture: how many captions it carries and how many had no clear spot. */
40402
+ reportLabels(input, labels) {
40403
+ const camera = input.camera === "composite" ? input.entry.payload.subject.deviceId : input.camera;
40404
+ this.deps.logger.debug("notification picture captions laid out", {
40405
+ tags: { deviceId: camera },
40406
+ meta: {
40407
+ ruleId: input.entry.ruleId,
40408
+ recordId: input.entry.recordId,
40409
+ placed: labels.placed,
40410
+ dropped: labels.dropped
40411
+ }
40412
+ });
40413
+ }
40200
40414
  /** The subject's trail, read once per render for its box AND its path. */
40201
40415
  async subjectTrail(camera, trackId) {
40202
40416
  const read = this.deps.getSubjectTrail;
@@ -46890,6 +47104,27 @@ function isRecord(value) {
46890
47104
  return typeof value === "object" && value !== null && !Array.isArray(value);
46891
47105
  }
46892
47106
  /**
47107
+ * Incident 2026-10-22: a row stored with `groupingSec: 0` failed `NcRuleSchema` (min
47108
+ * {@link NC_GROUPING_MIN_SEC}) and the WHOLE rule was skipped as malformed on every load — a
47109
+ * system-event rule on which a window cannot even apply vanished from the engine and from
47110
+ * `listRules`. A non-positive (or non-numeric) window only ever meant "no window": dropped. A
47111
+ * positive one outside the range is clamped. One bad optional field never costs the rule.
47112
+ */
47113
+ function repairStoredGroupingSec(value) {
47114
+ if (value === void 0) return { kind: "unchanged" };
47115
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return {
47116
+ kind: "repaired",
47117
+ from: value,
47118
+ to: null
47119
+ };
47120
+ const clamped = clampGroupingSec(value);
47121
+ return clamped === value ? { kind: "unchanged" } : {
47122
+ kind: "repaired",
47123
+ from: value,
47124
+ to: clamped
47125
+ };
47126
+ }
47127
+ /**
46893
47128
  * Project a RAW stored rule value (`data['rule']`, before any Zod parse). A value that is not
46894
47129
  * an object, or has no string `delivery`, is returned untouched: the schema parse that follows
46895
47130
  * rejects it exactly as before, and a projection must never be what decides a row is valid.
@@ -46897,9 +47132,12 @@ function isRecord(value) {
46897
47132
  function projectRawStoredRule(raw) {
46898
47133
  if (!isRecord(raw) || typeof raw["delivery"] !== "string") return {
46899
47134
  candidate: raw,
46900
- projection: { kind: "unchanged" }
47135
+ projection: { kind: "unchanged" },
47136
+ groupingRepair: { kind: "unchanged" }
46901
47137
  };
46902
- const groupingSec = raw["groupingSec"];
47138
+ const groupingRepair = repairStoredGroupingSec(raw["groupingSec"]);
47139
+ const repaired = withRepairedGrouping(raw, groupingRepair);
47140
+ const groupingSec = repaired["groupingSec"];
46903
47141
  const projection = projectRetiredRuleFields({
46904
47142
  delivery: raw["delivery"],
46905
47143
  conditions: isRecord(raw["conditions"]) ? raw["conditions"] : {},
@@ -46908,15 +47146,25 @@ function projectRawStoredRule(raw) {
46908
47146
  ...typeof groupingSec === "number" ? { groupingSec } : {}
46909
47147
  });
46910
47148
  if (projection.kind === "unchanged" || projection.groupingSec === void 0) return {
46911
- candidate: raw,
46912
- projection
47149
+ candidate: repaired,
47150
+ projection,
47151
+ groupingRepair
46913
47152
  };
46914
47153
  return {
46915
47154
  candidate: {
46916
- ...raw,
47155
+ ...repaired,
46917
47156
  groupingSec: projection.groupingSec
46918
47157
  },
46919
- projection
47158
+ projection,
47159
+ groupingRepair
47160
+ };
47161
+ }
47162
+ function withRepairedGrouping(raw, repair) {
47163
+ if (repair.kind === "unchanged") return raw;
47164
+ const { groupingSec: _dropped, ...rest } = raw;
47165
+ return repair.to === null ? rest : {
47166
+ ...rest,
47167
+ groupingSec: repair.to
46920
47168
  };
46921
47169
  }
46922
47170
  /**
@@ -46969,17 +47217,45 @@ var LOAD_LIMIT = 1e4;
46969
47217
  * Parse a raw body object (as returned from storage — may contain retired keys)
46970
47218
  * into an `NcRule` applying the legacy field projection.
46971
47219
  */
46972
- function bodyToRule(id, raw, onProjection) {
46973
- const { candidate, projection } = projectRawStoredRule(raw);
47220
+ function bodyToRule(id, raw, onProjection, onGroupingRepair) {
47221
+ const parsed = parseStoredRule(id, raw, onProjection, onGroupingRepair);
47222
+ return parsed.ok ? parsed.rule : null;
47223
+ }
47224
+ function parseStoredRule(id, raw, onProjection, onGroupingRepair, onClassWidening) {
47225
+ const { candidate, projection, groupingRepair } = projectRawStoredRule(raw);
46974
47226
  const parsed = NcRuleSchema.safeParse({
46975
47227
  ...candidate,
46976
47228
  id
46977
47229
  });
46978
- if (!parsed.success) return null;
47230
+ if (!parsed.success) {
47231
+ const issue = parsed.error.issues[0];
47232
+ return {
47233
+ ok: false,
47234
+ reason: issue === void 0 ? "invalid" : `${issue.path.join(".") || "(root)"}: ${issue.message}`
47235
+ };
47236
+ }
46979
47237
  if (projection.kind === "projected") onProjection?.(id, projection);
46980
- return withNormalizedMedia(parsed.data);
47238
+ if (groupingRepair.kind === "repaired") onGroupingRepair?.(id, groupingRepair);
47239
+ const media = withNormalizedMedia(parsed.data);
47240
+ const rule = withSystemEventClasses(media);
47241
+ if (rule !== media) onClassWidening?.(id, media, rule);
47242
+ return {
47243
+ ok: true,
47244
+ rule
47245
+ };
46981
47246
  }
46982
47247
  /**
47248
+ * D906: what a rule is stored as — its media policy folded (D897) and, on an
47249
+ * INSTANT system rule, every paired class it touches completed with both
47250
+ * edges. Applied on every WRITE; the load applies the same through
47251
+ * {@link parseStoredRule}, which also says once that it widened a rule.
47252
+ */
47253
+ function normalizeStoredRule(rule) {
47254
+ return withSystemEventClasses(withNormalizedMedia(rule));
47255
+ }
47256
+ /** At most this many skipped rows are NAMED in the WARN line; the count is always whole. */
47257
+ var SKIPPED_ROWS_NAMED_MAX = 10;
47258
+ /**
46983
47259
  * D897: the rule with its media policy in the stored shape — `image` set, the
46984
47260
  * legacy `attach` / `frame` / `zoneCrop` folded into it. Applied on LOAD (a read
46985
47261
  * projection: a rule nobody saved since keeps its row as written) and on every
@@ -47055,6 +47331,8 @@ var NcRuleStore = class NcRuleStore {
47055
47331
  now;
47056
47332
  newId;
47057
47333
  identityIdsByName;
47334
+ /** Rows the last successful {@link load} skipped; `null` before one succeeded. */
47335
+ skipped = null;
47058
47336
  /** Has a {@link load} ever completed? See the note there. */
47059
47337
  loaded = false;
47060
47338
  /** Legacy detection rules naming sounds already reported (D690) — once each. */
@@ -47066,6 +47344,10 @@ var NcRuleStore = class NcRuleStore {
47066
47344
  pendingProjections = /* @__PURE__ */ new Map();
47067
47345
  /** Rules whose projection was already logged in this process (D692 §9) — once each. */
47068
47346
  projectionLogged = /* @__PURE__ */ new Set();
47347
+ /** Rules whose out-of-range `groupingSec` repair was already logged — once each. */
47348
+ groupingRepairLogged = /* @__PURE__ */ new Set();
47349
+ /** Rules whose class widening (D906) was already logged — once each. */
47350
+ classWideningLogged = /* @__PURE__ */ new Set();
47069
47351
  constructor(deps) {
47070
47352
  this.logger = deps.logger;
47071
47353
  this.now = deps.now ?? (() => Date.now());
@@ -47100,20 +47382,29 @@ var NcRuleStore = class NcRuleStore {
47100
47382
  try {
47101
47383
  const { records } = await this.kind.query({ limit: LOAD_LIMIT });
47102
47384
  const next = /* @__PURE__ */ new Map();
47103
- let skipped = 0;
47385
+ const skippedRows = [];
47104
47386
  for (const row of records) {
47105
- const rule = bodyToRule(row.id, row.data, (ruleId, projection) => {
47387
+ const parsed = parseStoredRule(row.id, row.data, (ruleId, projection) => {
47106
47388
  this.pendingProjections.set(ruleId, projection);
47107
- });
47108
- if (rule === null) {
47109
- skipped += 1;
47389
+ }, (ruleId, repair) => this.logGroupingRepair(ruleId, repair), (ruleId, before, after) => this.logClassWidening(ruleId, before, after));
47390
+ if (!parsed.ok) {
47391
+ const name = row.data["name"];
47392
+ skippedRows.push({
47393
+ ruleId: row.id,
47394
+ name: typeof name === "string" ? name : null,
47395
+ reason: parsed.reason
47396
+ });
47110
47397
  continue;
47111
47398
  }
47112
- next.set(row.id, rule);
47399
+ next.set(row.id, parsed.rule);
47113
47400
  }
47114
47401
  this.byId.clear();
47115
47402
  for (const [id, rule] of next) this.byId.set(id, rule);
47116
- if (skipped > 0) this.logger.warn("notification rule rows skipped as malformed", { meta: { skipped } });
47403
+ this.skipped = skippedRows;
47404
+ if (skippedRows.length > 0) this.logger.warn("notification rule rows skipped as malformed", { meta: {
47405
+ skipped: skippedRows.length,
47406
+ rows: skippedRows.slice(0, SKIPPED_ROWS_NAMED_MAX)
47407
+ } });
47117
47408
  this.loaded = true;
47118
47409
  } catch (err) {
47119
47410
  this.logger.warn("notification rules load failed — keeping rules already in memory", { meta: {
@@ -47125,6 +47416,27 @@ var NcRuleStore = class NcRuleStore {
47125
47416
  for (const id of warnLegacyAudioClassRules([...this.byId.values()], this.audioClassWarned, this.logger)) this.audioClassWarned.add(id);
47126
47417
  this.logger.debug("notification rules loaded", { meta: { rules: this.byId.size } });
47127
47418
  }
47419
+ /** Say, once per process per rule, that an instant rule was read as its whole class (D906). */
47420
+ logClassWidening(ruleId, before, after) {
47421
+ if (this.classWideningLogged.has(ruleId)) return;
47422
+ this.classWideningLogged.add(ruleId);
47423
+ const had = new Set(before.conditions.systemEvent?.kinds ?? []);
47424
+ this.logger.info("instant system rule read as its whole event class — both edges", { meta: {
47425
+ ruleId,
47426
+ rule: after.name,
47427
+ added: (after.conditions.systemEvent?.kinds ?? []).filter((kind) => !had.has(kind))
47428
+ } });
47429
+ }
47430
+ /** Say, once per process per rule, that an out-of-range stored `groupingSec` was repaired. */
47431
+ logGroupingRepair(ruleId, repair) {
47432
+ if (this.groupingRepairLogged.has(ruleId)) return;
47433
+ this.groupingRepairLogged.add(ruleId);
47434
+ this.logger.warn("rule carries an out-of-range groupingSec — repaired on read", { meta: {
47435
+ ruleId,
47436
+ from: repair.from,
47437
+ to: repair.to
47438
+ } });
47439
+ }
47128
47440
  /** Log, once per process per rule, what `bodyToRule` projected from a retired field. */
47129
47441
  logPendingProjections() {
47130
47442
  for (const [ruleId, projection] of this.pendingProjections) {
@@ -47193,6 +47505,13 @@ var NcRuleStore = class NcRuleStore {
47193
47505
  * camera's ring) must ask this first: before the first load the empty list
47194
47506
  * is not a fact, and a failed load leaves whatever was already mirrored.
47195
47507
  */
47508
+ /**
47509
+ * The rows the last SUCCESSFUL load could not parse — `null` until one has
47510
+ * succeeded, so "not read yet" never reads as "nothing broken" (D315).
47511
+ */
47512
+ skippedRows() {
47513
+ return this.skipped;
47514
+ }
47196
47515
  isLoaded() {
47197
47516
  return this.loaded;
47198
47517
  }
@@ -47222,7 +47541,7 @@ var NcRuleStore = class NcRuleStore {
47222
47541
  /** Create a new rule. `createdBy` is the SERVER-injected caller userId. */
47223
47542
  async create(input, createdBy) {
47224
47543
  const now = this.now();
47225
- const rule = withNormalizedMedia({
47544
+ const rule = normalizeStoredRule({
47226
47545
  ...input,
47227
47546
  id: this.newId(),
47228
47547
  createdBy,
@@ -47267,7 +47586,7 @@ var NcRuleStore = class NcRuleStore {
47267
47586
  createdAt: existing.createdAt,
47268
47587
  updatedAt: this.now()
47269
47588
  };
47270
- const updated = withNormalizedMedia(NcRuleSchema.parse(candidate));
47589
+ const updated = normalizeStoredRule(NcRuleSchema.parse(candidate));
47271
47590
  await this.write(updated);
47272
47591
  return updated;
47273
47592
  }
@@ -47307,7 +47626,7 @@ var NcRuleStore = class NcRuleStore {
47307
47626
  id: ruleId
47308
47627
  } : candidate);
47309
47628
  if (!parsed.success) return existing;
47310
- const next = withNormalizedMedia(parsed.data);
47629
+ const next = normalizeStoredRule(parsed.data);
47311
47630
  const { id: _omit, ...data } = next;
47312
47631
  await this.kind.put({
47313
47632
  id: ruleId,
@@ -53336,6 +53655,104 @@ var NcZoneOwnershipCache = class {
53336
53655
  }
53337
53656
  };
53338
53657
  //#endregion
53658
+ //#region src/notification-center/rule-health.ts
53659
+ var EMPTY_SNAPSHOT = {
53660
+ timelapse: [],
53661
+ summary: [],
53662
+ alarm: null,
53663
+ mutedDeviceIds: []
53664
+ };
53665
+ function fingerprintOf(reasons) {
53666
+ return reasons.map((r) => `${r.code}:${[...r.ids].sort().join(",")}`).join("|");
53667
+ }
53668
+ /** The deleted subjects a rule still names, from the prune's `kept*` records. */
53669
+ function deletedReasons(rule, prunes) {
53670
+ const snapshot = {
53671
+ ...EMPTY_SNAPSHOT,
53672
+ rules: [rule]
53673
+ };
53674
+ const devices = referencedDeviceIds(snapshot);
53675
+ const targets = referencedTargetIds(snapshot);
53676
+ const deadDevices = /* @__PURE__ */ new Set();
53677
+ const deadTargets = /* @__PURE__ */ new Set();
53678
+ for (const p of prunes) {
53679
+ if (p.kind !== "rule" || p.ruleId !== rule.id) continue;
53680
+ if (p.outcome !== "kept" && p.outcome !== "kept-disabled") continue;
53681
+ if (p.subjectKind === "device" && devices.has(Number(p.subject))) deadDevices.add(p.subject);
53682
+ if (p.subjectKind === "target" && targets.has(p.subject)) deadTargets.add(p.subject);
53683
+ }
53684
+ const reasons = [];
53685
+ if (deadDevices.size > 0) reasons.push({
53686
+ code: "device-deleted",
53687
+ ids: [...deadDevices].sort()
53688
+ });
53689
+ if (deadTargets.size > 0) reasons.push({
53690
+ code: "target-deleted",
53691
+ ids: [...deadTargets].sort()
53692
+ });
53693
+ return reasons;
53694
+ }
53695
+ var NcRuleHealthMonitor = class {
53696
+ verdicts = /* @__PURE__ */ new Map();
53697
+ /** Judge every rule once; return the edges to announce. */
53698
+ evaluate(inputs) {
53699
+ const next = /* @__PURE__ */ new Map();
53700
+ const seen = /* @__PURE__ */ new Set();
53701
+ if (inputs.skipped !== null) for (const row of inputs.skipped) {
53702
+ seen.add(row.ruleId);
53703
+ const unparseable = {
53704
+ code: "unparseable",
53705
+ ids: [],
53706
+ detail: row.reason
53707
+ };
53708
+ next.set(row.ruleId, this.verdict(row.name, unparseable, []));
53709
+ }
53710
+ if (inputs.rules !== null) for (const rule of inputs.rules) {
53711
+ seen.add(rule.id);
53712
+ const previous = this.verdicts.get(rule.id);
53713
+ const deleted = inputs.prunes !== null ? deletedReasons(rule, inputs.prunes) : previous?.deleted ?? [];
53714
+ const unparseable = inputs.skipped !== null ? null : previous?.unparseable ?? null;
53715
+ next.set(rule.id, this.verdict(rule.name, unparseable, deleted));
53716
+ }
53717
+ const edges = [];
53718
+ const answeredAll = inputs.rules !== null && inputs.skipped !== null;
53719
+ for (const [ruleId, previous] of this.verdicts) {
53720
+ if (seen.has(ruleId)) continue;
53721
+ if (!answeredAll) next.set(ruleId, previous);
53722
+ }
53723
+ for (const [ruleId, verdict] of next) {
53724
+ const previous = this.verdicts.get(ruleId);
53725
+ const reasons = reasonsOf(verdict);
53726
+ if (reasons.length > 0) {
53727
+ if (previous?.fingerprint !== verdict.fingerprint) edges.push({
53728
+ kind: "broken",
53729
+ ruleId,
53730
+ ruleName: verdict.name,
53731
+ reasons
53732
+ });
53733
+ } else if (previous !== void 0 && reasonsOf(previous).length > 0) edges.push({
53734
+ kind: "repaired",
53735
+ ruleId,
53736
+ ruleName: verdict.name
53737
+ });
53738
+ }
53739
+ this.verdicts.clear();
53740
+ for (const [ruleId, verdict] of next) if (reasonsOf(verdict).length > 0) this.verdicts.set(ruleId, verdict);
53741
+ return edges;
53742
+ }
53743
+ verdict(name, unparseable, deleted) {
53744
+ return {
53745
+ name,
53746
+ unparseable,
53747
+ deleted,
53748
+ fingerprint: fingerprintOf([...unparseable !== null ? [unparseable] : [], ...deleted])
53749
+ };
53750
+ }
53751
+ };
53752
+ function reasonsOf(verdict) {
53753
+ return [...verdict.unparseable !== null ? [verdict.unparseable] : [], ...verdict.deleted];
53754
+ }
53755
+ //#endregion
53339
53756
  //#region src/notification-center/index.ts
53340
53757
  /**
53341
53758
  * NotificationCenter — the P1 core of the Notification Center (spec
@@ -53402,7 +53819,7 @@ var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
53402
53819
  * media (the SOURCE rule of a combined alarm row), and a zone overlay is
53403
53820
  * frozen only when the rule asked for one AND fired on a zone.
53404
53821
  */
53405
- function mediaPolicyFields(mediaRule, cropZoneIds) {
53822
+ function mediaPolicyFields(mediaRule, cropZoneIds, matchedZoneIds) {
53406
53823
  const media = effectiveMediaPolicyForRule(mediaRule);
53407
53824
  const image = media.image ?? "crop";
53408
53825
  return {
@@ -53411,6 +53828,7 @@ function mediaPolicyFields(mediaRule, cropZoneIds) {
53411
53828
  ...media.showTriggerZone === true && cropZoneIds !== void 0 && cropZoneIds.length > 0 ? { mediaShowTriggerZone: true } : {},
53412
53829
  ...media.showSubjectPath === true && image !== "none" ? { mediaShowSubjectPath: true } : {},
53413
53830
  ...cropZoneIds !== void 0 ? { triggerZoneIds: [...cropZoneIds] } : {},
53831
+ ...matchedZoneIds !== void 0 ? { matchedZoneIds: [...matchedZoneIds] } : {},
53414
53832
  ...media.profile !== void 0 ? { mediaProfile: media.profile } : {},
53415
53833
  ...media.gif === true ? { mediaGif: true } : {},
53416
53834
  ...media.clip === true ? { mediaClip: true } : {},
@@ -53916,6 +54334,21 @@ function groupOrderInput(fate, triggerZoneIds) {
53916
54334
  * condition's ids, else an occupancy rule's one zone. A grouped member freezes the same
53917
54335
  * ids a plain row of the rule would.
53918
54336
  */
54337
+ /**
54338
+ * The zones of `cropZoneIds` this subject actually triggered — what the zone
54339
+ * condition saw (membership plus the crossed zone, `zonesVisitedBy`), on the
54340
+ * rule whose media the row carries. On a combined alarm row the subject is the
54341
+ * alarm, so the source match's frozen zones answer. An occupancy rule's one
54342
+ * zone is the zone the count edge is ABOUT, so it is its own trigger.
54343
+ */
54344
+ function matchedTriggerZoneIds(mediaRule, cropZoneIds, subject) {
54345
+ if (cropZoneIds === void 0) return void 0;
54346
+ if (mediaRule.conditions.zones === void 0) return [...cropZoneIds];
54347
+ const alarmSource = subject.systemEvent?.alarmSource;
54348
+ const visited = new Set(alarmSource !== void 0 ? alarmSource.zones ?? [] : subject.zones);
54349
+ if (alarmSource === void 0 && subject.crossing !== void 0) visited.add(subject.crossing.zoneId);
54350
+ return cropZoneIds.filter((id) => visited.has(id));
54351
+ }
53919
54352
  function ruleCropZoneIds(rule) {
53920
54353
  if (rule.conditions.zones !== void 0) return rule.conditions.zones.ids;
53921
54354
  const zoneId = rule.conditions.occupancy?.zoneId;
@@ -54069,6 +54502,8 @@ var NotificationCenter = class {
54069
54502
  * evaluating one.
54070
54503
  */
54071
54504
  pruneLedger;
54505
+ /** D905: which rules cannot work, announced once per distinct reason. */
54506
+ ruleHealth = new NcRuleHealthMonitor();
54072
54507
  /** The prune itself — see `device-prune-runner.ts`. */
54073
54508
  pruner;
54074
54509
  /**
@@ -57498,7 +57933,7 @@ var NotificationCenter = class {
57498
57933
  const subject = incoming.subject;
57499
57934
  const alarmSource = subject.systemEvent?.alarmSource;
57500
57935
  const sourceRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) : null;
57501
- const media = sourceRule !== null ? mediaPolicyFields(sourceRule, ruleCropZoneIds(sourceRule)) : { media: "none" };
57936
+ const media = sourceRule !== null ? mediaPolicyFields(sourceRule, ruleCropZoneIds(sourceRule), matchedTriggerZoneIds(sourceRule, ruleCropZoneIds(sourceRule), subject)) : { media: "none" };
57502
57937
  const mediaDeviceId = alarmSource?.deviceId ?? panel.deviceId;
57503
57938
  const entries = targets.map((targetId) => ({
57504
57939
  ruleId: NC_ALARM_ANNOUNCE_RULE_ID,
@@ -58704,6 +59139,7 @@ var NotificationCenter = class {
58704
59139
  const renderedZones = notificationZones(rule, subject);
58705
59140
  const confirm = frozenConfirmPolicy(rule, isSystemEvent);
58706
59141
  const cropZoneIds = ruleCropZoneIds(mediaRule);
59142
+ const matchedZoneIds = matchedTriggerZoneIds(mediaRule, cropZoneIds, subject);
58707
59143
  const allTargets = [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))];
58708
59144
  const linkedOwners = !noMedia && subject.linkedStills !== void 0 ? linkedMediaOwners(subject.linkedStills, linkedCameraMode(mediaRule.media)) : void 0;
58709
59145
  const groupV2 = extras?.groupV2 ?? (subject.systemEvent !== void 0 && !noMedia ? alarmIncidentGroup(subject.systemEvent, subject.timestamp) : void 0);
@@ -58714,7 +59150,7 @@ var NotificationCenter = class {
58714
59150
  ...origin === "synthetic" ? { synthetic: true } : {},
58715
59151
  priority: rule.priority,
58716
59152
  ...rule.template !== void 0 ? { template: rule.template } : {},
58717
- ...noMedia ? { media: "none" } : mediaPolicyFields(mediaRule, cropZoneIds),
59153
+ ...noMedia ? { media: "none" } : mediaPolicyFields(mediaRule, cropZoneIds, matchedZoneIds),
58718
59154
  ...confirm !== void 0 ? {
58719
59155
  confirm,
58720
59156
  ...cooldown !== void 0 ? { cooldown } : {},
@@ -59359,6 +59795,51 @@ var NotificationCenter = class {
59359
59795
  if (!this.pruneLedger.isLoaded()) await this.pruneLedger.load();
59360
59796
  await this.pruner.reconcileDevices();
59361
59797
  await this.pruner.reconcileTargets();
59798
+ this.announceRuleHealth();
59799
+ }
59800
+ /**
59801
+ * D905 — say which rules cannot work: a row the load skipped, or a rule still
59802
+ * naming a device / target the prune confirmed deleted. One edge per rule per
59803
+ * distinct set of reasons; a reading that did not answer changes nothing.
59804
+ */
59805
+ announceRuleHealth() {
59806
+ const edges = this.ruleHealth.evaluate({
59807
+ rules: this.rules.isLoaded() ? this.rules.list() : null,
59808
+ skipped: this.rules.skippedRows(),
59809
+ prunes: this.pruneLedger.isLoaded() ? this.pruner.listPrunes() : null
59810
+ });
59811
+ for (const edge of edges) {
59812
+ const source = {
59813
+ type: "addon",
59814
+ id: "notification-center"
59815
+ };
59816
+ if (edge.kind === "broken") {
59817
+ const [first] = edge.reasons;
59818
+ if (first === void 0) continue;
59819
+ const offendingIds = [...new Set(edge.reasons.flatMap((r) => r.ids))];
59820
+ this.logger.warn("notification rule cannot work — announced", { meta: {
59821
+ ruleId: edge.ruleId,
59822
+ rule: edge.ruleName,
59823
+ reasons: edge.reasons
59824
+ } });
59825
+ this.deps.emitBusEvent?.(createEvent(EventCategory.NotificationRuleBroken, source, {
59826
+ ruleId: edge.ruleId,
59827
+ ruleName: edge.ruleName,
59828
+ reason: first.code,
59829
+ reasons: edge.reasons,
59830
+ offendingIds
59831
+ }));
59832
+ } else {
59833
+ this.logger.info("notification rule works again — announced", { meta: {
59834
+ ruleId: edge.ruleId,
59835
+ rule: edge.ruleName
59836
+ } });
59837
+ this.deps.emitBusEvent?.(createEvent(EventCategory.NotificationRuleRepaired, source, {
59838
+ ruleId: edge.ruleId,
59839
+ ruleName: edge.ruleName
59840
+ }));
59841
+ }
59842
+ }
59362
59843
  }
59363
59844
  /**
59364
59845
  * `DeviceUnregistered` — a device was DELETED (only `removeDevice` emits it,
@@ -103428,6 +103909,8 @@ var NC_SYSTEM_EVENT_CATEGORIES = [
103428
103909
  EventCategory.StorageLocationLow,
103429
103910
  EventCategory.StorageLocationCritical,
103430
103911
  EventCategory.StorageLocationOk,
103912
+ EventCategory.NotificationRuleBroken,
103913
+ EventCategory.NotificationRuleRepaired,
103431
103914
  EventCategory.AddonRunnerFailed,
103432
103915
  EventCategory.AddonStarted,
103433
103916
  EventCategory.AddonRestarted,
@@ -115458,6 +115941,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
115458
115941
  nodeAddons: (nodeId) => api.nodes.getNodeAddons.query({ nodeId })
115459
115942
  },
115460
115943
  readAlerts: () => api.alerts.list.query({ limit: 5e3 }),
115944
+ emitBusEvent: (event) => this.ctx.eventBus.emit(event),
115461
115945
  getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
115462
115946
  recognitionOutlook: ({ deviceId, trackId, className, labelKind, now }) => recognitionOutlook({
115463
115947
  className,