@camstack/addon-post-analysis 1.2.18 → 1.2.20

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 { A as errMsg, B as number, D as subKindsOf, E as plateGalleryCapability, F as nodePin, H as string, I as _enum, L as array, M as DeviceType, N as createEvent, O as videoclipsCapability, P as hydrateSchema, R as boolean, S as faceGalleryCapability, T as pipelineAnalyticsCapability, U as EventCategory, V as object, _ as buildEventKindDescriptor, a as NC_CONDITION_CATALOG, b as defineCustomActions, c as NcRuleInputSchema, d as NcTaxonomySchema, f as OpsLogEntrySchema, g as audioMetricsCapability, h as addonWidgetsSourceCapability, i as MACRO_LABELS, j as BaseAddon, k as zoneAnalyticsCapability, l as NcRulePatchSchema, m as TimelapseRuleSchema, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as TimelapseRuleInputSchema, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as cosineSimilarity, w as notificationRulesCapability, y as customAction, z as literal } from "../dist-C41w6Xvl.mjs";
1
+ import { A as errMsg, B as number, D as subKindsOf, E as plateGalleryCapability, F as nodePin, H as string, I as _enum, L as array, M as DeviceType, N as createEvent, O as videoclipsCapability, P as hydrateSchema, R as boolean, S as faceGalleryCapability, T as pipelineAnalyticsCapability, U as EventCategory, V as object, _ as buildEventKindDescriptor, a as NC_CONDITION_CATALOG, b as defineCustomActions, c as NcRuleInputSchema, d as NcTaxonomySchema, f as OpsLogEntrySchema, g as audioMetricsCapability, h as addonWidgetsSourceCapability, i as MACRO_LABELS, j as BaseAddon, k as zoneAnalyticsCapability, l as NcRulePatchSchema, m as TimelapseRuleSchema, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as TimelapseRuleInputSchema, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as cosineSimilarity, w as notificationRulesCapability, y as customAction, z as literal } from "../dist-CrAkB8NZ.mjs";
2
2
  import { promises } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
@@ -1435,6 +1435,94 @@ function resolveDetectionLabel(input) {
1435
1435
  if (top) return top;
1436
1436
  return input.originalClass && input.originalClass !== input.className ? input.originalClass : void 0;
1437
1437
  }
1438
+ var DEFAULT_ZONE_TRANSITION_CONFIG = {
1439
+ enterFrames: 2,
1440
+ exitFrames: 4
1441
+ };
1442
+ var NO_TRANSITIONS = {
1443
+ entered: [],
1444
+ exited: []
1445
+ };
1446
+ function newState() {
1447
+ return {
1448
+ inside: /* @__PURE__ */ new Set(),
1449
+ enterStreak: /* @__PURE__ */ new Map(),
1450
+ exitStreak: /* @__PURE__ */ new Map()
1451
+ };
1452
+ }
1453
+ var ZoneTransitionTracker = class {
1454
+ config;
1455
+ tracks = /* @__PURE__ */ new Map();
1456
+ constructor(config = {}) {
1457
+ this.config = {
1458
+ ...DEFAULT_ZONE_TRANSITION_CONFIG,
1459
+ ...config
1460
+ };
1461
+ }
1462
+ /**
1463
+ * Feed one frame's zone membership for one track.
1464
+ *
1465
+ * `currentZoneIds` is the set the geometry says the box is in RIGHT NOW; the
1466
+ * return value is only what has persisted long enough to be believed.
1467
+ */
1468
+ observe(trackId, currentZoneIds) {
1469
+ const state = this.tracks.get(trackId) ?? newState();
1470
+ this.tracks.set(trackId, state);
1471
+ const now = new Set(currentZoneIds);
1472
+ const entered = [];
1473
+ const exited = [];
1474
+ for (const zoneId of now) {
1475
+ if (state.inside.has(zoneId)) {
1476
+ state.exitStreak.delete(zoneId);
1477
+ continue;
1478
+ }
1479
+ const streak = (state.enterStreak.get(zoneId) ?? 0) + 1;
1480
+ if (streak >= this.config.enterFrames) {
1481
+ state.enterStreak.delete(zoneId);
1482
+ state.inside.add(zoneId);
1483
+ entered.push(zoneId);
1484
+ } else state.enterStreak.set(zoneId, streak);
1485
+ }
1486
+ for (const zoneId of [...state.enterStreak.keys()]) if (!now.has(zoneId)) state.enterStreak.delete(zoneId);
1487
+ for (const zoneId of [...state.inside]) {
1488
+ if (now.has(zoneId)) continue;
1489
+ const streak = (state.exitStreak.get(zoneId) ?? 0) + 1;
1490
+ if (streak >= this.config.exitFrames) {
1491
+ state.exitStreak.delete(zoneId);
1492
+ state.inside.delete(zoneId);
1493
+ exited.push(zoneId);
1494
+ } else state.exitStreak.set(zoneId, streak);
1495
+ }
1496
+ if (entered.length === 0 && exited.length === 0) return NO_TRANSITIONS;
1497
+ return {
1498
+ entered,
1499
+ exited
1500
+ };
1501
+ }
1502
+ /** Zones a track is currently confirmed to be inside. */
1503
+ zonesFor(trackId) {
1504
+ const state = this.tracks.get(trackId);
1505
+ return state ? [...state.inside] : [];
1506
+ }
1507
+ /**
1508
+ * Drop a track and report the zones it was still inside.
1509
+ *
1510
+ * The caller uses these to close the subject's presence — a track that
1511
+ * disappears mid-zone has genuinely left it, and nothing else will ever say
1512
+ * so. Without this, a zone entry would have no matching exit whenever the
1513
+ * subject walked out of frame rather than out of the zone.
1514
+ */
1515
+ forget(trackId) {
1516
+ const state = this.tracks.get(trackId);
1517
+ if (!state) return [];
1518
+ this.tracks.delete(trackId);
1519
+ return [...state.inside];
1520
+ }
1521
+ /** Number of tracks held — a leak check for the caller's tests. */
1522
+ size() {
1523
+ return this.tracks.size;
1524
+ }
1525
+ };
1438
1526
  //#endregion
1439
1527
  //#region src/pipeline-analytics/pipeline/zones/geometry.ts
1440
1528
  /** Ray-casting point-in-polygon test */
@@ -2296,22 +2384,48 @@ function resolveRuleThreshold(rule) {
2296
2384
  */
2297
2385
  var ZoneEngine = class {
2298
2386
  /**
2299
- * Annotate a single detection with its zone memberships.
2300
- * Returns zones where the detection overlaps above any active
2301
- * rule's threshold (or the engine default if no rule sets one).
2387
+ * Annotate a single detection with its zone memberships — every zone whose
2388
+ * polygon the detection overlaps by MORE than `minOverlap` (0–1 fraction of
2389
+ * the detection's own area).
2390
+ *
2391
+ * The previous version of this comment claimed memberships were returned
2392
+ * "above any active rule's threshold"; they were not — the threshold was
2393
+ * hardcoded to {@link MEMBERSHIP_MIN_OVERLAP} (zero) and no rule was ever
2394
+ * consulted. Read the parameter, not this paragraph.
2395
+ *
2396
+ * `minOverlap` matters because membership is what lands on an event as
2397
+ * `zones`, and a notification rule's `zones` condition is a plain set test
2398
+ * over that field — so this, not the zone-RULE threshold, is what decides
2399
+ * whether a zone-scoped notification fires. At the default of 0 a subject
2400
+ * clipping a zone by one pixel counts as inside it (measured 2026-07-30: a
2401
+ * dog overlapping `Aiuola` by 4.8% would have been stamped as in it).
2302
2402
  */
2303
- annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight) {
2403
+ annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
2404
+ return [...this.splitDetectionZones(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap).memberships];
2405
+ }
2406
+ /**
2407
+ * {@link annotateDetection}, keeping the zones the bar REJECTED as well as
2408
+ * the ones it admitted. Same geometry, one pass — see
2409
+ * {@link ZoneMembershipSplit} for why the rejected side has to survive.
2410
+ */
2411
+ splitDetectionZones(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
2304
2412
  const memberships = [];
2413
+ const belowBar = [];
2305
2414
  for (const zone of zones) {
2306
2415
  const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
2307
2416
  const overlap = mask && maskWidth && maskHeight ? maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, pixelPolygon, frameWidth, frameHeight) : bboxPolygonOverlap(bbox, pixelPolygon);
2308
- if (overlap > MEMBERSHIP_MIN_OVERLAP) memberships.push({
2417
+ const entry = {
2309
2418
  zoneId: zone.id,
2310
2419
  zoneName: zone.name,
2311
2420
  overlap
2312
- });
2421
+ };
2422
+ if (overlap > minOverlap) memberships.push(entry);
2423
+ else if (overlap > 0) belowBar.push(entry);
2313
2424
  }
2314
- return memberships;
2425
+ return {
2426
+ memberships,
2427
+ belowBar
2428
+ };
2315
2429
  }
2316
2430
  /**
2317
2431
  * Filter detections through a zone-rule set. `zones` provides the
@@ -2372,6 +2486,66 @@ function ruleApplies(resolved, det, className, maskInfo, _zones, frameWidth, fra
2372
2486
  return false;
2373
2487
  }
2374
2488
  //#endregion
2489
+ //#region src/pipeline-analytics/pipeline/rider-pairing.ts
2490
+ /**
2491
+ * Fine classes that carry a rider. A scooter/moped reaches us as
2492
+ * `motorcycle` (COCO) or `motorbike` depending on the model's label set.
2493
+ */
2494
+ var TWO_WHEELERS = new Set([
2495
+ "bicycle",
2496
+ "motorcycle",
2497
+ "motorbike"
2498
+ ]);
2499
+ /** Intersection area of two boxes. */
2500
+ function intersection(a, b) {
2501
+ return Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
2502
+ }
2503
+ /** True when `vehicle` is a two-wheeler that `person` is riding. */
2504
+ function isRiderPair(person, vehicle) {
2505
+ if (person.macroClass !== "person") return false;
2506
+ if (vehicle.macroClass !== "vehicle") return false;
2507
+ if (!TWO_WHEELERS.has((vehicle.originalClass ?? "").toLowerCase())) return false;
2508
+ const personArea = person.bbox.w * person.bbox.h;
2509
+ if (!(personArea > 0)) return false;
2510
+ if (intersection(person.bbox, vehicle.bbox) / personArea < .25) return false;
2511
+ return person.bbox.y + person.bbox.h / 2 < vehicle.bbox.y + vehicle.bbox.h / 2;
2512
+ }
2513
+ /**
2514
+ * Pair every rider with their machine. A person is paired at most once — with
2515
+ * the two-wheeler they overlap MOST — so two bikes side by side cannot both
2516
+ * claim the same rider.
2517
+ */
2518
+ function pairRiders(detections) {
2519
+ const people = detections.filter((d) => d.macroClass === "person");
2520
+ const twoWheelers = detections.filter((d) => d.macroClass === "vehicle" && TWO_WHEELERS.has((d.originalClass ?? "").toLowerCase()));
2521
+ if (people.length === 0 || twoWheelers.length === 0) return [];
2522
+ const pairs = [];
2523
+ const claimed = /* @__PURE__ */ new Set();
2524
+ for (const person of people) {
2525
+ let best = null;
2526
+ const personArea = person.bbox.w * person.bbox.h;
2527
+ if (!(personArea > 0)) continue;
2528
+ for (const vehicle of twoWheelers) {
2529
+ if (claimed.has(vehicle.id)) continue;
2530
+ if (!isRiderPair(person, vehicle)) continue;
2531
+ const overlap = intersection(person.bbox, vehicle.bbox) / personArea;
2532
+ if (!best || overlap > best.overlap) best = {
2533
+ vehicle,
2534
+ overlap
2535
+ };
2536
+ }
2537
+ if (best) {
2538
+ claimed.add(best.vehicle.id);
2539
+ pairs.push({
2540
+ personId: person.id,
2541
+ vehicleId: best.vehicle.id,
2542
+ overlap: Math.round(best.overlap * 1e3) / 1e3
2543
+ });
2544
+ }
2545
+ }
2546
+ return pairs;
2547
+ }
2548
+ //#endregion
2375
2549
  //#region src/pipeline-analytics/pipeline/frame-processor.ts
2376
2550
  /** Mapping from StateAnalyzer's `ObjectState.state` values to the
2377
2551
  * canonical TrackState enum used on tracks + events. */
@@ -2405,6 +2579,21 @@ var FrameProcessor = class {
2405
2579
  * runners that haven't picked up the new gating yet.
2406
2580
  */
2407
2581
  detectionRules;
2582
+ /** See {@link setZoneMembershipMinOverlap}. 0 = any positive overlap. */
2583
+ zoneMembershipMinOverlap;
2584
+ /** See {@link getLastZoneOverlaps}. */
2585
+ lastZoneOverlaps;
2586
+ /** See {@link getLastZoneRejections}. */
2587
+ lastZoneRejections;
2588
+ /** Zone crossings per track — the producer of `zone.enter` / `zone.exit`. */
2589
+ zoneTransitions = new ZoneTransitionTracker();
2590
+ /**
2591
+ * Last zone membership seen per track, so an event for a track that is GONE
2592
+ * this frame still carries the zones it was in. See the use site.
2593
+ */
2594
+ lastZonesByTrack = /* @__PURE__ */ new Map();
2595
+ /** See {@link getLastRiderPairs}. */
2596
+ lastRiderPairs = [];
2408
2597
  zoneEngine = new ZoneEngine();
2409
2598
  /** Optional stationary-object gate (parked-object suppression). Null until
2410
2599
  * the addon wires it via {@link setStationaryGate}. */
@@ -2417,10 +2606,46 @@ var FrameProcessor = class {
2417
2606
  this.eventEmitter = new DetectionEventEmitter(emitterConfig);
2418
2607
  this.zones = [];
2419
2608
  this.detectionRules = [];
2609
+ this.zoneMembershipMinOverlap = 0;
2610
+ this.lastZoneOverlaps = /* @__PURE__ */ new Map();
2611
+ this.lastZoneRejections = /* @__PURE__ */ new Map();
2420
2612
  }
2421
2613
  setZones(zones) {
2422
2614
  this.zones = zones;
2423
2615
  }
2616
+ /**
2617
+ * How much of a detection's box must lie inside a zone for the zone to be
2618
+ * stamped onto it (0–1 fraction of the box's own area).
2619
+ *
2620
+ * DEFAULT 0 — byte-identical to the behaviour before 2026-07-31, where any
2621
+ * positive overlap counted. Raising it is an operator decision and needs
2622
+ * evidence: a bar set blind removes notifications silently, which is the
2623
+ * failure mode this whole area keeps producing. {@link lastZoneOverlaps}
2624
+ * exists so the distribution can be read before a number is picked.
2625
+ */
2626
+ setZoneMembershipMinOverlap(minOverlap) {
2627
+ this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
2628
+ }
2629
+ /** Rider pairings folded on the most recent frame — the PERSON half that was
2630
+ * dropped so the passage counts once, as a vehicle. Reported so both
2631
+ * entities stay on the record rather than one silently disappearing. */
2632
+ getLastRiderPairs() {
2633
+ return this.lastRiderPairs;
2634
+ }
2635
+ /** Per-track zone memberships WITH their overlap fractions, from the most
2636
+ * recent frame. The engine computes these and the pipeline previously
2637
+ * discarded everything but the ids — which is why no amount of production
2638
+ * data could say how far inside the zone a notifying subject actually was. */
2639
+ getLastZoneOverlaps() {
2640
+ return this.lastZoneOverlaps;
2641
+ }
2642
+ /** Per-track zones the membership bar REJECTED (0 < overlap ≤ the bar), from
2643
+ * the most recent frame. Raising the bar otherwise makes the stamping log go
2644
+ * quiet for exactly the cases it was armed to measure — see
2645
+ * `ZoneMembershipSplit` in `zones/zone-engine.ts`. */
2646
+ getLastZoneRejections() {
2647
+ return this.lastZoneRejections;
2648
+ }
2424
2649
  setDetectionRules(rules) {
2425
2650
  this.detectionRules = rules;
2426
2651
  }
@@ -2513,7 +2738,24 @@ var FrameProcessor = class {
2513
2738
  });
2514
2739
  }
2515
2740
  const { passed } = this.zoneEngine.filterDetections(flatDetections, this.zones, this.detectionRules, frameWidth, frameHeight, (fd) => fd.detection.class);
2516
- const filteredDetections = passed.map((fd) => fd.detection);
2741
+ let filteredDetections = passed.map((fd) => fd.detection);
2742
+ const riderPairs = pairRiders(filteredDetections.map((d, i) => ({
2743
+ id: String(i),
2744
+ macroClass: d.class,
2745
+ ...d.originalClass !== void 0 ? { originalClass: d.originalClass } : {},
2746
+ bbox: d.bbox,
2747
+ score: d.score
2748
+ })));
2749
+ if (riderPairs.length > 0) {
2750
+ const riderIdx = new Set(riderPairs.map((p) => Number(p.personId)));
2751
+ this.lastRiderPairs = riderPairs.map((p) => ({
2752
+ overlap: p.overlap,
2753
+ personScore: filteredDetections[Number(p.personId)]?.score ?? 0,
2754
+ vehicleScore: filteredDetections[Number(p.vehicleId)]?.score ?? 0,
2755
+ vehicleClass: filteredDetections[Number(p.vehicleId)]?.originalClass ?? "two-wheeler"
2756
+ }));
2757
+ filteredDetections = filteredDetections.filter((_, i) => !riderIdx.has(i));
2758
+ } else if (this.lastRiderPairs.length > 0) this.lastRiderPairs = [];
2517
2759
  const gate = this.stationaryGate ? this.stationaryGate.filter({
2518
2760
  detections: filteredDetections,
2519
2761
  frameWidth,
@@ -2529,12 +2771,46 @@ var FrameProcessor = class {
2529
2771
  frameHeight
2530
2772
  });
2531
2773
  const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
2532
- const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
2533
2774
  const zonesByTrack = /* @__PURE__ */ new Map();
2775
+ const overlapsByTrack = /* @__PURE__ */ new Map();
2776
+ const rejectedByTrack = /* @__PURE__ */ new Map();
2534
2777
  for (const td of trackedDetections) {
2535
2778
  const m = maskByBbox.get(td.bbox);
2536
- const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height);
2779
+ const { memberships, belowBar } = this.zoneEngine.splitDetectionZones(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height, this.zoneMembershipMinOverlap);
2537
2780
  zonesByTrack.set(td.trackId, memberships.map((m2) => m2.zoneId));
2781
+ if (memberships.length > 0) overlapsByTrack.set(td.trackId, memberships);
2782
+ if (belowBar.length > 0) rejectedByTrack.set(td.trackId, belowBar);
2783
+ }
2784
+ this.lastZoneOverlaps = overlapsByTrack;
2785
+ this.lastZoneRejections = rejectedByTrack;
2786
+ for (const [trackId, zoneIds] of zonesByTrack) this.lastZonesByTrack.set(trackId, zoneIds);
2787
+ for (const state of objectStates) {
2788
+ if (zonesByTrack.has(state.trackId)) continue;
2789
+ const remembered = this.lastZonesByTrack.get(state.trackId);
2790
+ if (remembered && remembered.length > 0) zonesByTrack.set(state.trackId, remembered);
2791
+ }
2792
+ const zoneEvents = [];
2793
+ const pushZoneEvent = (type, zoneId, detection) => {
2794
+ const zone = this.zones.find((z) => z.id === zoneId);
2795
+ zoneEvents.push({
2796
+ type,
2797
+ zoneId,
2798
+ zoneName: zone?.name ?? zoneId,
2799
+ trackId: detection.trackId,
2800
+ detection,
2801
+ timestamp
2802
+ });
2803
+ };
2804
+ for (const td of trackedDetections) {
2805
+ const crossings = this.zoneTransitions.observe(td.trackId, zonesByTrack.get(td.trackId) ?? []);
2806
+ for (const zoneId of crossings.entered) pushZoneEvent("zone-enter", zoneId, td);
2807
+ for (const zoneId of crossings.exited) pushZoneEvent("zone-exit", zoneId, td);
2808
+ }
2809
+ const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, zoneEvents, [], String(this.deviceId));
2810
+ for (const state of objectStates) {
2811
+ if (state.state !== "leaving") continue;
2812
+ this.zoneTransitions.forget(state.trackId);
2813
+ this.lastZonesByTrack.delete(state.trackId);
2538
2814
  }
2539
2815
  const tracked = trackedDetections.map((td) => {
2540
2816
  const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
@@ -2573,10 +2849,22 @@ var FrameProcessor = class {
2573
2849
  } : {}
2574
2850
  };
2575
2851
  });
2852
+ const crossingOf = (e) => {
2853
+ const direction = e.type === "zone.enter" ? "enter" : e.type === "zone.exit" ? "exit" : void 0;
2854
+ if (direction === void 0) return void 0;
2855
+ const ze = e.zoneEvents[0];
2856
+ if (ze === void 0) return void 0;
2857
+ return {
2858
+ direction,
2859
+ zoneId: ze.zoneId,
2860
+ zoneName: ze.zoneName
2861
+ };
2862
+ };
2576
2863
  const toObjectEvent = (e, forcedState) => {
2577
2864
  const td = trackedDetections.find((t) => t.trackId === e.detection.trackId);
2578
2865
  const state = forcedState ?? mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
2579
2866
  const zones = zonesByTrack.get(e.detection.trackId) ?? [];
2867
+ const crossing = crossingOf(e);
2580
2868
  const label = td ? resolveDetectionLabel({
2581
2869
  className: td.class,
2582
2870
  originalClass: td.originalClass,
@@ -2600,6 +2888,7 @@ var FrameProcessor = class {
2600
2888
  },
2601
2889
  zones,
2602
2890
  state,
2891
+ ...crossing !== void 0 ? { zoneCrossing: crossing } : {},
2603
2892
  ...label ? { label } : {},
2604
2893
  frameWidth,
2605
2894
  frameHeight
@@ -3498,7 +3787,8 @@ var DEFAULT_ZERO_MEDIA_POLICY_CONFIG = {
3498
3787
  * for the full contract.
3499
3788
  */
3500
3789
  function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG) {
3501
- if (input.hasMedia) return "persist";
3790
+ if (input.hasBestMedia) return "persist";
3791
+ if (input.hasMedia) return input.hasRasterFallback ? "raster-fallback" : "persist";
3502
3792
  if (input.durationMs < config.suppressMaxDurationMs && !input.confirmed) return "suppress";
3503
3793
  if (!input.hasRasterFallback && input.durationMs < config.nothingToShowMaxDurationMs) return "suppress";
3504
3794
  return input.hasRasterFallback ? "raster-fallback" : "persist";
@@ -3692,9 +3982,11 @@ var TrackCloser = class {
3692
3982
  const duration = t.lastSeen - t.firstSeen;
3693
3983
  const closure = this.deps.residents.closure(t.trackId);
3694
3984
  const ownedMedia = await this.deps.mediaStore()?.listByOwner("track", t.trackId) ?? [];
3985
+ const hasBestMedia = ownedMedia.some((m) => m.kind === "thumbnail" || m.kind === "keyFrame");
3695
3986
  const outcome = decideZeroMediaPolicy({
3696
3987
  durationMs: duration,
3697
3988
  hasMedia: ownedMedia.length > 0,
3989
+ hasBestMedia,
3698
3990
  confirmed: closure?.confirmed ?? false,
3699
3991
  hasRasterFallback: closure?.rasterFallback !== void 0
3700
3992
  });
@@ -4036,6 +4328,7 @@ function subjectFromObjectEvent(ev) {
4036
4328
  ...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
4037
4329
  source: ev.source ?? "pipeline",
4038
4330
  ...ev.importance !== void 0 ? { importance: ev.importance } : {},
4331
+ ...ev.zoneCrossing !== void 0 ? { crossing: ev.zoneCrossing } : {},
4039
4332
  ...bboxPatch(normalizeBbox(ev.bbox, ev.frameWidth, ev.frameHeight))
4040
4333
  };
4041
4334
  }
@@ -4202,6 +4495,7 @@ function presentConditionIds(c) {
4202
4495
  if (c.minDwellSeconds !== void 0) ids.push("minDwellSeconds");
4203
4496
  if (c.zones !== void 0) ids.push("zones");
4204
4497
  if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) ids.push("zonesExclude");
4498
+ if (c.crossing !== void 0 && c.crossing !== "enter") ids.push("crossing");
4205
4499
  if (c.labelEquals !== void 0 && c.labelEquals.length > 0) ids.push("labelEquals");
4206
4500
  if (c.identities !== void 0 && c.identities.length > 0) ids.push("identities");
4207
4501
  if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) ids.push("identitiesExclude");
@@ -4237,6 +4531,21 @@ function matchesOccupancy(occ, s) {
4237
4531
  case "<=": return s.occupied === false && s.threshold === occ.count + 1;
4238
4532
  }
4239
4533
  }
4534
+ /**
4535
+ * The zone ids a `zones` / `zonesExclude` condition tests against: the
4536
+ * record's membership PLUS the zone it crossed, when it is a crossing.
4537
+ *
4538
+ * The union is what makes an EXIT addressable. Membership is computed from the
4539
+ * box's current geometry, so on the frame an exit is confirmed the subject is
4540
+ * by definition no longer in the zone — a rule scoped to "Uscio" would never
4541
+ * see the exit from Uscio. An ENTRY's zone is already in the membership, so
4542
+ * this changes nothing for every rule that exists today.
4543
+ */
4544
+ function zonesVisitedBy(subject) {
4545
+ const visited = new Set(subject.zones);
4546
+ if (subject.crossing !== void 0) visited.add(subject.crossing.zoneId);
4547
+ return visited;
4548
+ }
4240
4549
  function toLowerSet(values) {
4241
4550
  return new Set(values.map((v) => v.trim().toLowerCase()));
4242
4551
  }
@@ -4322,14 +4631,20 @@ function evaluateRule(rule, subject) {
4322
4631
  if (c.minDwellSeconds !== void 0) {
4323
4632
  if (subject.dwellSeconds === void 0 || subject.dwellSeconds < c.minDwellSeconds) return fail("minDwellSeconds");
4324
4633
  }
4634
+ if (c.crossing !== "any") {
4635
+ const direction = subject.crossing?.direction;
4636
+ if (c.crossing === "exit") {
4637
+ if (direction !== "exit") return fail("crossing");
4638
+ } else if (direction === "exit") return fail("crossing");
4639
+ }
4325
4640
  if (c.zones !== void 0) {
4326
- const visited = new Set(subject.zones);
4641
+ const visited = zonesVisitedBy(subject);
4327
4642
  if (c.zones.match === "all") {
4328
4643
  for (const id of c.zones.ids) if (!visited.has(id)) return fail("zones");
4329
4644
  } else if (!c.zones.ids.some((id) => visited.has(id))) return fail("zones");
4330
4645
  }
4331
4646
  if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) {
4332
- const visited = new Set(subject.zones);
4647
+ const visited = zonesVisitedBy(subject);
4333
4648
  if (c.zonesExclude.some((id) => visited.has(id))) return fail("zonesExclude");
4334
4649
  }
4335
4650
  if (c.customZones !== void 0 && c.customZones.length > 0) {
@@ -4458,10 +4773,28 @@ function matchesPlate(label, values, maxDistance) {
4458
4773
  for (const v of values) if (levenshtein(plate, normalizePlate(v)) <= maxDistance) return true;
4459
4774
  return false;
4460
4775
  }
4461
- /** Stable cooldown key per the rule's throttle scope. */
4776
+ /**
4777
+ * Should this subject's class be part of the cooldown key?
4778
+ *
4779
+ * AUDIO is unconditional and predates the setting: a rule opted into several
4780
+ * audio classes (dog + scream) must not have a safety-relevant scream
4781
+ * swallowed by an unrelated bark's 60 s window.
4782
+ *
4783
+ * Every other subject gets the same treatment only when the rule ASKS for it
4784
+ * (`granularity: 'per-class'`) — cat→dog fires at once, cat→cat still waits.
4785
+ * Absent / `shared` reproduces the class-agnostic key byte for byte, because
4786
+ * changing how often an operator's existing rules fire is not a side effect
4787
+ * anyone asked for.
4788
+ */
4789
+ function keysPerClass(rule, subject) {
4790
+ if (subject.kind === "audio-event") return true;
4791
+ return rule.throttle.granularity === "per-class";
4792
+ }
4793
+ /** Stable cooldown key per the rule's throttle scope + class granularity. */
4462
4794
  function cooldownKey(rule, subject) {
4463
- const audioClass = subject.kind === "audio-event" && subject.classNames[0] !== void 0 ? `:c:${subject.classNames[0]}` : "";
4464
- return rule.throttle.scope === "rule" ? `r:${rule.id}${audioClass}` : `r:${rule.id}:d:${subject.deviceId}${audioClass}`;
4795
+ const first = subject.classNames[0];
4796
+ const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
4797
+ return rule.throttle.scope === "rule" ? `r:${rule.id}${classKey}` : `r:${rule.id}:d:${subject.deviceId}${classKey}`;
4465
4798
  }
4466
4799
  /** True when the rule fired within its cooldown window before `now`. */
4467
4800
  function isCoolingDown(rule, lastFiredAt, now) {
@@ -4629,7 +4962,9 @@ var NcDispatcher = class {
4629
4962
  ruleId: entry.ruleId,
4630
4963
  target: target.name,
4631
4964
  kind: target.kind,
4632
- recordKind: entry.recordKind
4965
+ recordKind: entry.recordKind,
4966
+ eventId: entry.recordId,
4967
+ ...entry.trackId !== void 0 ? { trackId: entry.trackId } : {}
4633
4968
  }
4634
4969
  });
4635
4970
  return { ok: true };
@@ -4667,9 +5002,10 @@ var NcDispatcher = class {
4667
5002
  async buildNotification(entry) {
4668
5003
  const subject = entry.payload.subject;
4669
5004
  const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
4670
- const vars = buildTemplateVars(entry, deviceName);
5005
+ const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
5006
+ const vars = buildTemplateVars(entry, deviceName, zoneLabels);
4671
5007
  const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
4672
- const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
5008
+ const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
4673
5009
  const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
4674
5010
  const params = pickParams(entry.payload.params);
4675
5011
  return {
@@ -4868,15 +5204,37 @@ var NcDispatcher = class {
4868
5204
  return null;
4869
5205
  }
4870
5206
  };
4871
- function buildTemplateVars(entry, deviceName) {
5207
+ /**
5208
+ * Map admin zone IDs to their display names for rendering only.
5209
+ *
5210
+ * Order follows `zoneIds` (the order the track visited them), not the zone
5211
+ * catalog. Every failure mode degrades to the ID rather than dropping the
5212
+ * zone: an unknown id, a blank name, a throwing lookup, or no lookup wired at
5213
+ * all. A body that silently loses a zone is worse than one that shows a UUID.
5214
+ */
5215
+ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
5216
+ if (zoneIds.length === 0) return [];
5217
+ if (getZoneNames === void 0) return [...zoneIds];
5218
+ try {
5219
+ const zones = await getZoneNames(deviceId);
5220
+ const byId = new Map(zones.map((z) => [z.id, z.name]));
5221
+ return zoneIds.map((id) => {
5222
+ const name = byId.get(id);
5223
+ return name !== void 0 && name.trim().length > 0 ? name : id;
5224
+ });
5225
+ } catch {
5226
+ return [...zoneIds];
5227
+ }
5228
+ }
5229
+ function buildTemplateVars(entry, deviceName, zoneLabels) {
4872
5230
  const subject = entry.payload.subject;
4873
5231
  const occupancy = subject.occupancy;
4874
5232
  return {
4875
5233
  camera: deviceName,
4876
5234
  class: subject.className,
4877
5235
  label: subject.label ?? "",
4878
- zones: subject.zones.join(", "),
4879
- zone: occupancy?.zone ?? subject.zones[0] ?? "",
5236
+ zones: zoneLabels.join(", "),
5237
+ zone: occupancy?.zone ?? zoneLabels[0] ?? "",
4880
5238
  confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
4881
5239
  time: new Date(subject.timestamp).toLocaleTimeString(),
4882
5240
  rule: entry.payload.ruleName,
@@ -4894,12 +5252,12 @@ function renderTemplate(template, vars) {
4894
5252
  if (template === void 0 || template.trim().length === 0) return null;
4895
5253
  return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
4896
5254
  }
4897
- function defaultBody(entry, deviceName) {
5255
+ function defaultBody(entry, deviceName, zoneLabels) {
4898
5256
  const subject = entry.payload.subject;
4899
5257
  const occupancy = subject.occupancy;
4900
5258
  if (occupancy !== void 0) return `${occupancy.zone ?? deviceName} ${occupancyOpWord(occupancy.occupied)} (${occupancy.count}/${occupancy.capacity})`;
4901
5259
  const label = subject.label !== void 0 ? ` (${subject.label})` : "";
4902
- const zones = subject.zones.length > 0 ? ` in ${subject.zones.join(", ")}` : "";
5260
+ const zones = zoneLabels.length > 0 ? ` in ${zoneLabels.join(", ")}` : "";
4903
5261
  const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
4904
5262
  return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
4905
5263
  }
@@ -6226,6 +6584,13 @@ var TimelapseStore = class {
6226
6584
  };
6227
6585
  //#endregion
6228
6586
  //#region src/notification-center/index.ts
6587
+ /**
6588
+ * How often the "matched NO rule" report may fire per device. Long enough that
6589
+ * a busy camera prints one line rather than one per event, short enough that a
6590
+ * rule which has stopped matching is visible within minutes rather than by
6591
+ * comparison with another system fifteen hours later.
6592
+ */
6593
+ var NO_MATCH_REPORT_INTERVAL_MS = 6e4;
6229
6594
  var DEFAULT_DRAIN_INTERVAL_MS = 2e3;
6230
6595
  var DEFAULT_RULE_RELOAD_INTERVAL_MS = 3e4;
6231
6596
  var DEFAULT_RECONCILE_WINDOW_MS = 15 * 6e4;
@@ -6336,6 +6701,9 @@ var NotificationCenter = class NotificationCenter {
6336
6701
  occupancyEnabled = false;
6337
6702
  /** In-memory cooldown map — seeded from persisted outbox rows on start. */
6338
6703
  lastFiredAt = /* @__PURE__ */ new Map();
6704
+ /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
6705
+ lastNoMatchReportAt = /* @__PURE__ */ new Map();
6706
+ noMatchSuppressed = /* @__PURE__ */ new Map();
6339
6707
  /**
6340
6708
  * Serialized evaluation chain. The persist hooks are fire-and-forget for
6341
6709
  * the frame path, but two concurrent evaluations of the same device
@@ -6667,9 +7035,12 @@ var NotificationCenter = class NotificationCenter {
6667
7035
  return;
6668
7036
  }
6669
7037
  const now = this.now();
7038
+ let anyMatched = false;
7039
+ const rejections = [];
6670
7040
  for (const rule of candidates) {
6671
7041
  const evaluation = evaluateRule(rule, subject);
6672
7042
  if (!evaluation.matched) {
7043
+ rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
6673
7044
  this.logger.debug("rule did not match", {
6674
7045
  tags: { deviceId: subject.deviceId },
6675
7046
  meta: {
@@ -6677,7 +7048,9 @@ var NotificationCenter = class NotificationCenter {
6677
7048
  rule: rule.name,
6678
7049
  kind,
6679
7050
  failed: evaluation.failedCondition,
6680
- classes: subject.classNames
7051
+ classes: subject.classNames,
7052
+ eventId: subject.recordId,
7053
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
6681
7054
  }
6682
7055
  });
6683
7056
  continue;
@@ -6689,7 +7062,9 @@ var NotificationCenter = class NotificationCenter {
6689
7062
  meta: {
6690
7063
  ruleId: rule.id,
6691
7064
  rule: rule.name,
6692
- key
7065
+ key,
7066
+ eventId: subject.recordId,
7067
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
6693
7068
  }
6694
7069
  });
6695
7070
  continue;
@@ -6700,12 +7075,42 @@ var NotificationCenter = class NotificationCenter {
6700
7075
  ruleId: rule.id,
6701
7076
  rule: rule.name,
6702
7077
  kind,
6703
- targets: rule.targets.length
7078
+ targets: rule.targets.length,
7079
+ eventId: subject.recordId,
7080
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
7081
+ ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
7082
+ ...subject.zones.length > 0 ? { zones: subject.zones } : {}
6704
7083
  }
6705
7084
  });
6706
7085
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
6707
7086
  if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
7087
+ anyMatched = true;
6708
7088
  }
7089
+ if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
7090
+ }
7091
+ /** See the call site. Bounded to one line per device per window. */
7092
+ reportNoMatch(subject, kind, rejections, now) {
7093
+ const last = this.lastNoMatchReportAt.get(subject.deviceId) ?? 0;
7094
+ const suppressed = this.noMatchSuppressed.get(subject.deviceId) ?? 0;
7095
+ if (now - last < NO_MATCH_REPORT_INTERVAL_MS) {
7096
+ this.noMatchSuppressed.set(subject.deviceId, suppressed + 1);
7097
+ return;
7098
+ }
7099
+ this.lastNoMatchReportAt.set(subject.deviceId, now);
7100
+ this.noMatchSuppressed.set(subject.deviceId, 0);
7101
+ this.logger.info("event matched NO rule", {
7102
+ tags: { deviceId: subject.deviceId },
7103
+ meta: {
7104
+ kind,
7105
+ rejectedBy: rejections,
7106
+ classes: subject.classNames,
7107
+ ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
7108
+ ...subject.zones.length > 0 ? { zones: subject.zones } : { zones: [] },
7109
+ eventId: subject.recordId,
7110
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
7111
+ ...suppressed > 0 ? { alsoSuppressedSinceLastReport: suppressed } : {}
7112
+ }
7113
+ });
6709
7114
  }
6710
7115
  buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
6711
7116
  const hasEventMedia = kind === "object-event" || kind === "package-event";
@@ -7800,6 +8205,63 @@ function classifyTrackAppearance(input) {
7800
8205
  return input.positionsCount > 1 ? "resurrection" : "birth";
7801
8206
  }
7802
8207
  //#endregion
8208
+ //#region src/pipeline-analytics/pipeline/suppressed-births.ts
8209
+ /**
8210
+ * Memory of births the confirmation gate rejected, per device.
8211
+ *
8212
+ * The gate runs AFTER the TrackStore upsert, and it has to: the upsert's
8213
+ * position count is what distinguishes a true birth from a tracker
8214
+ * resurrection. So a rejected birth has already been written by the time the
8215
+ * verdict exists. It correctly gets no `start`, no media and no notification —
8216
+ * but the record stayed, and surfaced in `listRecentTracks`. On 2026-07-31 six
8217
+ * full-frame phantoms on device 615 in nine minutes were every one of them
8218
+ * suppressed AND listed.
8219
+ *
8220
+ * Retracting once is not enough. The tracker keeps carrying a rejected id for
8221
+ * as long as the phantom persists — 27 seconds in the worst measured case — and
8222
+ * every later frame would write it straight back. So the rejection is
8223
+ * remembered until the id stops being tracked, and only then forgotten.
8224
+ *
8225
+ * Kept as its own module because the state is the part with real risk: forget
8226
+ * too early and the phantom returns, never forget and a camera producing
8227
+ * phantoms continuously grows this set for the process's lifetime.
8228
+ */
8229
+ var SuppressedBirthRegistry = class {
8230
+ byDevice = /* @__PURE__ */ new Map();
8231
+ /** Record that this birth was rejected; later frames must not re-upsert it. */
8232
+ reject(deviceKey, trackId) {
8233
+ let ids = this.byDevice.get(deviceKey);
8234
+ if (!ids) {
8235
+ ids = /* @__PURE__ */ new Set();
8236
+ this.byDevice.set(deviceKey, ids);
8237
+ }
8238
+ ids.add(trackId);
8239
+ }
8240
+ isRejected(deviceKey, trackId) {
8241
+ return this.byDevice.get(deviceKey)?.has(trackId) === true;
8242
+ }
8243
+ /**
8244
+ * Forget every rejected id the tracker is no longer carrying.
8245
+ *
8246
+ * Called once per frame with the ids present THIS frame. An id absent from
8247
+ * that set can never be upserted again, so holding it serves nothing.
8248
+ */
8249
+ retain(deviceKey, currentTrackIds) {
8250
+ const ids = this.byDevice.get(deviceKey);
8251
+ if (!ids) return;
8252
+ for (const id of ids) if (!currentTrackIds.has(id)) ids.delete(id);
8253
+ if (ids.size === 0) this.byDevice.delete(deviceKey);
8254
+ }
8255
+ /** Rejected ids currently held for a device — diagnostics and tests. */
8256
+ size(deviceKey) {
8257
+ return this.byDevice.get(deviceKey)?.size ?? 0;
8258
+ }
8259
+ /** Drop a device's memory wholesale (device removed / pipeline reset). */
8260
+ clearDevice(deviceKey) {
8261
+ this.byDevice.delete(deviceKey);
8262
+ }
8263
+ };
8264
+ //#endregion
7803
8265
  //#region src/pipeline-analytics/pipeline/key-event-query.ts
7804
8266
  async function rankKeyEvents(candidates, options, peakLookup) {
7805
8267
  const scored = [];
@@ -8304,9 +8766,9 @@ async function extractCrop(frameData, frameWidth, frameHeight, bbox) {
8304
8766
  * padding === 0 is the identity operation. Pure, immutable — always returns a
8305
8767
  * new object.
8306
8768
  *
8307
- * NOTE: clamping is single-sided when the box hugs an edge, the origin is
8308
- * clamped to 0/keeps the full padded extent against the opposite bound, so the
8309
- * crop can extend slightly further on the far side than the symmetric padding
8769
+ * NOTE: clamping SHIFTS, it does not truncate a window that overflows a
8770
+ * bound slides inward and keeps the extent that was asked for, on either side.
8771
+ * It shrinks only when the padded window is larger than the frame itself
8310
8772
  * would (e.g. a left-hugging box gains a bit more right-side context). This is
8311
8773
  * acceptable for detection crops (more context, never out of [0,1]); the
8312
8774
  * downstream `extractCrop` clamps to pixel bounds again regardless.
@@ -8316,13 +8778,13 @@ function padBbox(bbox, padding) {
8316
8778
  const rawY = bbox.y - padding * bbox.h;
8317
8779
  const rawW = bbox.w * (1 + 2 * padding);
8318
8780
  const rawH = bbox.h * (1 + 2 * padding);
8319
- const x = Math.max(0, rawX);
8320
- const y = Math.max(0, rawY);
8781
+ const w = Math.min(rawW, 1);
8782
+ const h = Math.min(rawH, 1);
8321
8783
  return {
8322
- x,
8323
- y,
8324
- w: Math.min(rawW, 1 - x),
8325
- h: Math.min(rawH, 1 - y)
8784
+ x: Math.min(Math.max(0, rawX), 1 - w),
8785
+ y: Math.min(Math.max(0, rawY), 1 - h),
8786
+ w,
8787
+ h
8326
8788
  };
8327
8789
  }
8328
8790
  //#endregion
@@ -12017,7 +12479,11 @@ function squareSubjectCropRegion(bbox, frame) {
12017
12479
  * cases). `C` becomes the middle square of the output.
12018
12480
  * 2. canvas: `canvasW = round(c × 16/9)`, `canvasH = c`.
12019
12481
  * 3. anchor: place the canvas so `C` is its horizontal middle →
12020
- * `frameOriginX = C.x − (canvasW − c)/2` (frame-x mapping to canvas-x 0).
12482
+ * `frameOriginX = C.x − (canvasW − c)/2` (frame-x mapping to canvas-x 0),
12483
+ * then CLAMP the window fully inside the frame whenever it fits. The
12484
+ * subject therefore drifts off-centre near a frame edge and the output
12485
+ * carries no padding at all. Centring is a preference; containing the
12486
+ * subject is the contract, and clamping cannot break it.
12021
12487
  * 4. in-frame slab: intersect `[frameOriginX, frameOriginX + canvasW]` with
12022
12488
  * `[0, W]`; the slab is that intersection at full height `c` (C ⊆ frame ⇒
12023
12489
  * the vertical extent is always in-frame). Its canvas offset is
@@ -12029,13 +12495,8 @@ function wideCentralSquareLayout(bbox, frame) {
12029
12495
  const canvasW = Math.round(c * 16 / 9);
12030
12496
  const centralX0 = Math.round((canvasW - c) / 2);
12031
12497
  let frameOriginX = central.x - (canvasW - c) / 2;
12032
- if (canvasW <= frame.W) {
12033
- const slideRightMax = Math.max(0, bbox.x - central.x);
12034
- const slideLeftMax = Math.max(0, central.x + c - (bbox.x + bbox.w));
12035
- if (frameOriginX < 0) frameOriginX += Math.min(-frameOriginX, slideRightMax);
12036
- const overRight = frameOriginX + canvasW - frame.W;
12037
- if (overRight > 0) frameOriginX -= Math.min(overRight, slideLeftMax);
12038
- } else frameOriginX = (frame.W - canvasW) / 2;
12498
+ if (canvasW <= frame.W) frameOriginX = Math.min(Math.max(0, frameOriginX), frame.W - canvasW);
12499
+ else frameOriginX = (frame.W - canvasW) / 2;
12039
12500
  const fxa = Math.max(0, frameOriginX);
12040
12501
  const slabW = Math.min(frame.W, frameOriginX + canvasW) - fxa;
12041
12502
  const slabOffsetX = fxa - frameOriginX;
@@ -12084,10 +12545,17 @@ var LETTERBOX_BLUR_BRIGHTNESS = .55;
12084
12545
  /**
12085
12546
  * Compose the native-resolution 16:9 central-square WINDOW (`thumbnail`) from the
12086
12547
  * in-frame slab fetched for `layout`. The slab is placed at its computed offset
12087
- * on a 16:9 canvas; any lateral part of the window outside the frame (a subject
12088
- * flush against a frame edge the geometry already slides the window in-frame
12089
- * wherever the subject has slack) is filled with a BLURRED, dimmed stretch of
12090
- * the slab itself instead of dead black bars (operator triage 2026-07-22).
12548
+ * on a 16:9 canvas; any lateral part of the window outside the frame is filled
12549
+ * with a BLURRED, dimmed stretch of the slab itself instead of dead black bars
12550
+ * (operator triage 2026-07-22).
12551
+ *
12552
+ * **That fill is now nearly unreachable, and deliberately so.** The geometry
12553
+ * clamps the window fully inside the frame whenever it fits, so a subject
12554
+ * against a frame edge yields real pixels off-centre rather than ambience
12555
+ * (operator directive 2026-07-31). Only a window WIDER THAN THE FRAME ITSELF
12556
+ * still pads — there is no more scene to slide into — which is why this code
12557
+ * stays. If you are looking at a blurred band in a best shot, the window was
12558
+ * wider than the frame; do not go looking for a sliding bug.
12091
12559
  * When the whole window is in-frame (`slab` already spans the full canvas) the
12092
12560
  * slab is returned VERBATIM (no re-encode). NEVER upscales the real image —
12093
12561
  * the canvas is at the slab's native scale; only the out-of-frame ambience fill
@@ -13210,6 +13678,13 @@ var ZoneAnalyticsProvider = class {
13210
13678
  zones: snapshot.zones.length
13211
13679
  }
13212
13680
  });
13681
+ this.ctx.emitOccupancyChanged?.({
13682
+ deviceId: input.deviceId,
13683
+ timestamp: input.timestamp,
13684
+ totalObjects: total,
13685
+ byClass: snapshot.frame.byClass,
13686
+ zones: snapshot.zones.length
13687
+ });
13213
13688
  }
13214
13689
  this.snapshots.set(input.deviceId, snapshot);
13215
13690
  this.appendHistory(input.deviceId, snapshot);
@@ -13847,6 +14322,23 @@ function resolveDetectionSensitivitySettings(raw) {
13847
14322
  };
13848
14323
  }
13849
14324
  var TrackingSettingsSchema = object({
14325
+ /**
14326
+ * How much of a detection's box must lie inside a zone (0-1 fraction of the
14327
+ * box's own area) for that zone to be STAMPED onto the detection.
14328
+ *
14329
+ * This is the field a zone-scoped notification rule ultimately depends on: a
14330
+ * rule's `zones` condition is a plain set test over the stamped zone ids, so
14331
+ * a subject that merely clips a zone edge satisfies it. Measured on
14332
+ * 2026-07-30: a dog overlapping `Aiuola` by 4.8% would have counted as
14333
+ * inside it.
14334
+ *
14335
+ * DEFAULT 0 — byte-identical to the behaviour before 2026-07-31. Raising it
14336
+ * is deliberately an operator decision: the overlap fractions are now logged
14337
+ * (`zone membership` lines), so the bar can be chosen from the distribution
14338
+ * instead of guessed. Distinct from a zone RULE's `bboxInclusionPct`, which
14339
+ * gates the DETECTION stage, not what gets stamped.
14340
+ */
14341
+ zoneMembershipMinOverlap: number().min(0).max(1).default(0),
13850
14342
  /** IoU required to match a (predicted) track to a detection. */
13851
14343
  iouThreshold: number().min(0).max(1).default(.3),
13852
14344
  /** Wall-clock coasting budget (ms) before a missed track is dropped. Time-
@@ -13983,6 +14475,7 @@ function resolveTrackingSettings(raw) {
13983
14475
  const maxMissedMs = raw.maxMissedMs !== void 0 ? s.maxMissedMs.catch(TRACKING_DEFAULTS.maxMissedMs).parse(raw.maxMissedMs) : raw.maxMissedFrames !== void 0 ? Math.round(maxMissedFrames * 133) : TRACKING_DEFAULTS.maxMissedMs;
13984
14476
  const occlusionMaxMissedMs = raw.occlusionMaxMissedMs !== void 0 ? s.occlusionMaxMissedMs.catch(TRACKING_DEFAULTS.occlusionMaxMissedMs).parse(raw.occlusionMaxMissedMs) : raw.occlusionMaxMissedFrames !== void 0 ? Math.round(occlusionMaxMissedFrames * 133) : TRACKING_DEFAULTS.occlusionMaxMissedMs;
13985
14477
  return {
14478
+ zoneMembershipMinOverlap: s.zoneMembershipMinOverlap.catch(TRACKING_DEFAULTS.zoneMembershipMinOverlap).parse(raw.zoneMembershipMinOverlap),
13986
14479
  iouThreshold: s.iouThreshold.catch(TRACKING_DEFAULTS.iouThreshold).parse(raw.iouThreshold),
13987
14480
  maxMissedMs,
13988
14481
  minTrackAgeMs: s.minTrackAgeMs.catch(TRACKING_DEFAULTS.minTrackAgeMs).parse(raw.minTrackAgeMs),
@@ -14037,7 +14530,11 @@ function resolveTrackingSettings(raw) {
14037
14530
  * low-res detection frame (a static "person" phantom, a parked-truck ghost, a
14038
14531
  * misclassified static object).
14039
14532
  *
14040
- * Ships DORMANT: `enabled` defaults to `false`, so behaviour is byte-identical
14533
+ * ON by default (`enabled` defaults to TRUE). An earlier version of this line
14534
+ * said "Ships DORMANT: `enabled` defaults to `false`" — that was stale, and on
14535
+ * 2026-07-30 it nearly produced the conclusion that the gate was not running at
14536
+ * all. It is: it suppressed several phantom births on device 615 that same day.
14537
+ * Read the schema, not this paragraph. Historically the intent was byte-identical
14041
14538
  * to today until an operator opts in per camera. The gate is fail-OPEN — any
14042
14539
  * missing frame handle, unavailable inference cap, crop-fetch miss, re-detection
14043
14540
  * error, or timeout ALLOWS the birth (a real track is never suppressed because
@@ -14133,10 +14630,11 @@ function isConfirmationCompatible(trackClassName, detectionMacroClass) {
14133
14630
  if (track === "other" || det === "other") return true;
14134
14631
  return track === det;
14135
14632
  }
14136
- var failOpen = (trackId, reason) => ({
14137
- trackId,
14633
+ var failOpen = (candidate, reason) => ({
14634
+ trackId: candidate.trackId,
14138
14635
  confirmed: true,
14139
- reason
14636
+ reason,
14637
+ className: candidate.className
14140
14638
  });
14141
14639
  function withTimeout(promise, timeoutMs) {
14142
14640
  return new Promise((resolve, reject) => {
@@ -14152,23 +14650,34 @@ function withTimeout(promise, timeoutMs) {
14152
14650
  }
14153
14651
  async function runConfirmation(candidate, config, deps) {
14154
14652
  const crop = await deps.fetchCrop(candidate);
14155
- if (!crop) return failOpen(candidate.trackId, "no-crop");
14653
+ if (!crop) return failOpen(candidate, "no-crop");
14156
14654
  const detections = await deps.redetect(crop);
14157
- if (detections === null) return failOpen(candidate.trackId, "redetect-error");
14158
- const confirmed = detections.some((d) => d.score >= config.minConfidence && isConfirmationCompatible(candidate.className, d.macroClass));
14655
+ if (detections === null) return failOpen(candidate, "redetect-error");
14656
+ let best;
14657
+ let bestIncompatible;
14658
+ for (const d of detections) if (isConfirmationCompatible(candidate.className, d.macroClass)) {
14659
+ if (!best || d.score > best.score) best = d;
14660
+ } else if (!bestIncompatible || d.score > bestIncompatible.score) bestIncompatible = d;
14661
+ const confirmed = best !== void 0 && best.score >= config.minConfidence;
14159
14662
  return {
14160
14663
  trackId: candidate.trackId,
14161
14664
  confirmed,
14162
- reason: confirmed ? "confirmed" : "suppressed"
14665
+ reason: confirmed ? "confirmed" : "suppressed",
14666
+ className: candidate.className,
14667
+ ...best ? { bestScore: best.score } : {},
14668
+ ...bestIncompatible ? {
14669
+ bestIncompatibleClass: bestIncompatible.macroClass,
14670
+ bestIncompatibleScore: bestIncompatible.score
14671
+ } : {}
14163
14672
  };
14164
14673
  }
14165
14674
  async function confirmOne(candidate, config, deps) {
14166
14675
  const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
14167
- if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate.trackId, "below-min-crop");
14676
+ if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate, "below-min-crop");
14168
14677
  try {
14169
14678
  return await withTimeout(runConfirmation(candidate, config, deps), config.timeoutMs);
14170
14679
  } catch {
14171
- return failOpen(candidate.trackId, "timeout");
14680
+ return failOpen(candidate, "timeout");
14172
14681
  }
14173
14682
  }
14174
14683
  /**
@@ -14351,8 +14860,23 @@ function resolveMediaSettings(raw) {
14351
14860
  * §3.3 draft said 45s).
14352
14861
  */
14353
14862
  var PackageDropSettingsSchema = object({
14354
- /** Master switch — off by default; opt-in per camera (porch/door cams). */
14355
- packageDropEnabled: boolean().default(false),
14863
+ /**
14864
+ * Explicit per-camera OFF. **On by default: the ZONE RULE is what enables
14865
+ * package detection**, not a second switch.
14866
+ *
14867
+ * As an opt-in default-false this was a parallel source of truth. Device 615
14868
+ * on 2026-07-30 had the zone (`Uscio`), an enabled `package`-stage zone rule
14869
+ * (`Pacchetti`, classFilter `['package']`), and a notification rule on
14870
+ * `delivery: 'package-event'` — three layers of operator intent, all defeated
14871
+ * silently by a boolean none of them mentions.
14872
+ *
14873
+ * Defaulting to true costs nothing on cameras nobody configured:
14874
+ * `PackageDropDetector.onAppeared` still returns early when the device has no
14875
+ * enabled `package`-stage rule, so the work is a class check plus one cached
14876
+ * lookup. Set this false to force the feature off on a camera that HAS a zone
14877
+ * rule.
14878
+ */
14879
+ packageDropEnabled: boolean().default(true),
14356
14880
  /**
14357
14881
  * Minimum OBSERVED dwell (seconds, since first-seen) before a newly
14358
14882
  * promoted stationary package counts as a delivery. Kills a bag briefly
@@ -14893,6 +15417,15 @@ function buildDetectionSettingsSections() {
14893
15417
  step: .05,
14894
15418
  default: TRACKING_DEFAULTS.rescueIouThreshold
14895
15419
  },
15420
+ {
15421
+ type: "number",
15422
+ key: "rescueCentroidFactor",
15423
+ label: "Rescue centroid distance",
15424
+ description: "The other half of the rescue gate: how far a detection’s centre may sit from the track’s last-known centre, as a fraction of the box diagonal. A rescue must pass BOTH this and the rescue IoU, so raising the IoU alone does not tighten re-attachment.",
15425
+ min: 0,
15426
+ step: .05,
15427
+ default: TRACKING_DEFAULTS.rescueCentroidFactor
15428
+ },
14896
15429
  {
14897
15430
  type: "number",
14898
15431
  key: "stationarySpeedPx",
@@ -15174,6 +15707,16 @@ function buildDetectionSettingsSections() {
15174
15707
  label: "Person/animal group matching",
15175
15708
  description: "Let a detection that flips between person and animal for a frame (a crouching / bending person is often read as an animal) re-match its existing track instead of spawning a concurrent animal track. The reported class is still decided by the per-track class vote.",
15176
15709
  default: TRACKING_DEFAULTS.classGroupAssoc
15710
+ },
15711
+ {
15712
+ type: "number",
15713
+ key: "zoneMembershipMinOverlap",
15714
+ label: "Zone membership minimum overlap",
15715
+ description: "How much of a detection box must fall inside a zone for the detection to be STAMPED with it — as a fraction of the BOX area, not of the zone. 0 stamps on any touch, which is what let a plant brushing a zone by 6% fire a \"person on the doorstep\" rule. Only a stamped zone can match a zone-scoped notification rule. Read the \"zone membership stamped on event\" log line to choose the value: it prints the achieved percentage per zone.",
15716
+ min: 0,
15717
+ max: 1,
15718
+ step: .05,
15719
+ default: TRACKING_DEFAULTS.zoneMembershipMinOverlap
15177
15720
  }
15178
15721
  ]
15179
15722
  },
@@ -19571,7 +20114,21 @@ function decodeEmbeddingBase64(base64) {
19571
20114
  const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
19572
20115
  return Array.from(view);
19573
20116
  }
20117
+ /** A track shorter than this is a candidate phantom, not a subject that came
20118
+ * and went. Brackets the observed plant tracks (3.8-19.0 s). */
20119
+ var SHORT_TRACK_MAX_MS = 25e3;
20120
+ /** Total displacement under this is "did not move at all" (observed: 0-2.5 px). */
20121
+ var MOTIONLESS_MAX_PX = 8;
20122
+ /** Grid the spawn point is quantised to, so respawns whose boxes never repeat
20123
+ * to the pixel still land in one cell. */
20124
+ var PHANTOM_CELL_PX = 32;
20125
+ /** How long a cell remembers its closes. */
20126
+ var PHANTOM_CELL_WINDOW_MS = 360 * 6e4;
19574
20127
  var PipelineAnalyticsAddon = class extends BaseAddon {
20128
+ /** Recent SHORT+MOTIONLESS track closes per `<device>:<class>:<cell>` —
20129
+ * see {@link noteShortMotionlessTrack}. Measurement only; each entry is
20130
+ * filtered against the 6-hour window on write, so it stays bounded. */
20131
+ shortMotionlessCells = /* @__PURE__ */ new Map();
19575
20132
  processors = /* @__PURE__ */ new Map();
19576
20133
  trackStore = null;
19577
20134
  /** Parked-object registry: promotes a track that stopped moving into a
@@ -19729,6 +20286,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
19729
20286
  * dataPlane facility in the current environment). */
19730
20287
  eventMediaBaseUrl = null;
19731
20288
  lastActiveTrackIds = /* @__PURE__ */ new Map();
20289
+ /** See `pipeline/suppressed-births.ts` — rejected births must not be
20290
+ * re-upserted, and must be forgotten when the tracker drops the id. */
20291
+ suppressedBirths = new SuppressedBirthRegistry();
19732
20292
  lastFrameDimsByDevice = /* @__PURE__ */ new Map();
19733
20293
  lastAudioInsertByDevice = /* @__PURE__ */ new Map();
19734
20294
  lastMotionInsertByDevice = /* @__PURE__ */ new Map();
@@ -19881,7 +20441,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
19881
20441
  });
19882
20442
  },
19883
20443
  emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
19884
- onTrackClosed: (track, ownedMedia, info) => this.notificationCenter?.onTrackClosed(track, ownedMedia, info),
20444
+ onTrackClosed: (track, ownedMedia, info) => {
20445
+ this.noteShortMotionlessTrack(track);
20446
+ return this.notificationCenter?.onTrackClosed(track, ownedMedia, info);
20447
+ },
19885
20448
  deriveThumbnailFromKeyFrame: async (input) => {
19886
20449
  const derived = await deriveKeyFrameThumbnailJpeg({
19887
20450
  ...input,
@@ -20309,6 +20872,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20309
20872
  const zoneAnalytics = new ZoneAnalyticsProvider({
20310
20873
  logger: logger.child("ZoneAnalytics"),
20311
20874
  fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
20875
+ emitOccupancyChanged: (payload) => this.ctx.eventBus.emit({
20876
+ id: `za-occ-${payload.deviceId}-${payload.timestamp}`,
20877
+ timestamp: new Date(payload.timestamp),
20878
+ source: {
20879
+ type: "addon",
20880
+ id: "pipeline-analytics",
20881
+ addonId: "pipeline-analytics"
20882
+ },
20883
+ category: EventCategory.ZoneAnalyticsOccupancyChanged,
20884
+ data: {
20885
+ deviceId: payload.deviceId,
20886
+ totalObjects: payload.totalObjects,
20887
+ byClass: payload.byClass,
20888
+ zones: payload.zones
20889
+ }
20890
+ }),
20312
20891
  listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
20313
20892
  listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
20314
20893
  resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
@@ -20368,6 +20947,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20368
20947
  };
20369
20948
  },
20370
20949
  dispatcher: {
20950
+ getZoneNames: async (deviceId) => {
20951
+ return (await api.zones.listZones.query({ deviceId })).map((z) => ({
20952
+ id: z.id,
20953
+ name: z.name
20954
+ }));
20955
+ },
20371
20956
  getZonePolygons: async (deviceId, zoneIds) => {
20372
20957
  const zones = await api.zones.listZones.query({ deviceId });
20373
20958
  const wanted = new Set(zoneIds);
@@ -20745,10 +21330,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20745
21330
  const liveRules = proxy?.state.zoneRules.value?.detection ?? [];
20746
21331
  processor.setZones(liveZones);
20747
21332
  processor.setDetectionRules(liveRules);
21333
+ processor.setZoneMembershipMinOverlap(trk.zoneMembershipMinOverlap);
20748
21334
  const result = processor.process({
20749
21335
  timestamp: frame.timestamp,
20750
21336
  frame
20751
21337
  });
21338
+ for (const r of processor.getLastRiderPairs()) this.ctx.logger.info("rider folded into vehicle", {
21339
+ tags: { deviceId },
21340
+ meta: {
21341
+ vehicleClass: r.vehicleClass,
21342
+ personScore: r.personScore,
21343
+ vehicleScore: r.vehicleScore,
21344
+ overlap: r.overlap
21345
+ }
21346
+ });
20752
21347
  if (source === "pipeline") this.stationaryRegistry?.noteFrame(deviceId, result.timestamp);
20753
21348
  if (this.stationaryRegistry && (result.stationaryConfirmed.length > 0 || result.stationaryWoken.length > 0)) this.stationaryRegistry.applyFrameOutcome({
20754
21349
  deviceId,
@@ -20779,6 +21374,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20779
21374
  const positionsCountById = /* @__PURE__ */ new Map();
20780
21375
  for (const t of result.tracked) {
20781
21376
  currentTrackIds.add(t.trackId);
21377
+ if (this.suppressedBirths.isRejected(key, t.trackId)) continue;
20782
21378
  const center = {
20783
21379
  x: t.bbox.x + t.bbox.w / 2,
20784
21380
  y: t.bbox.y + t.bbox.h / 2
@@ -20845,7 +21441,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20845
21441
  frameHeight: result.frameHeight
20846
21442
  });
20847
21443
  for (const { id, t } of bornCandidates) {
20848
- if (!confirmedBirths.has(id)) continue;
21444
+ if (!confirmedBirths.has(id)) {
21445
+ this.suppressedBirths.reject(key, id);
21446
+ this.trackStore?.dropActive(id);
21447
+ log.info("birth suppressed — track record retracted", { meta: {
21448
+ trackId: id,
21449
+ className: t.className,
21450
+ source
21451
+ } });
21452
+ continue;
21453
+ }
20849
21454
  newTrackCount += 1;
20850
21455
  log.info("track started", { meta: {
20851
21456
  trackId: id,
@@ -20906,6 +21511,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20906
21511
  source
20907
21512
  } });
20908
21513
  }
21514
+ this.suppressedBirths.retain(key, currentTrackIds);
20909
21515
  this.lastActiveTrackIds.set(key, currentTrackIds);
20910
21516
  const stationarySettings = this.stationarySettingsFromCache(deviceId);
20911
21517
  if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
@@ -20975,7 +21581,46 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
20975
21581
  } });
20976
21582
  }
20977
21583
  await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
20978
- if (this.notificationCenter !== null) for (const e of result.objectEvents) this.notificationCenter.onObjectEventPersisted(e);
21584
+ if (this.notificationCenter !== null) {
21585
+ const overlaps = processor.getLastZoneOverlaps();
21586
+ const rejections = processor.getLastZoneRejections();
21587
+ for (const e of result.objectEvents) {
21588
+ if (e.zones && e.zones.length > 0) {
21589
+ const m = e.trackId ? overlaps.get(e.trackId) : void 0;
21590
+ this.ctx.logger.info("zone membership stamped on event", {
21591
+ tags: { deviceId },
21592
+ meta: {
21593
+ eventId: e.id,
21594
+ trackId: e.trackId,
21595
+ className: e.className,
21596
+ minOverlap: trk.zoneMembershipMinOverlap,
21597
+ zones: (m ?? []).map((z) => ({
21598
+ id: z.zoneId,
21599
+ name: z.zoneName,
21600
+ overlapPct: Math.round(z.overlap * 1e3) / 10
21601
+ }))
21602
+ }
21603
+ });
21604
+ }
21605
+ const rejected = e.trackId ? rejections.get(e.trackId) : void 0;
21606
+ if (rejected !== void 0 && rejected.length > 0) this.ctx.logger.info("zone membership REJECTED by the bar", {
21607
+ tags: { deviceId },
21608
+ meta: {
21609
+ eventId: e.id,
21610
+ trackId: e.trackId,
21611
+ className: e.className,
21612
+ minOverlap: trk.zoneMembershipMinOverlap,
21613
+ stamped: e.zones?.length ?? 0,
21614
+ zones: rejected.map((z) => ({
21615
+ id: z.zoneId,
21616
+ name: z.zoneName,
21617
+ overlapPct: Math.round(z.overlap * 1e3) / 10
21618
+ }))
21619
+ }
21620
+ });
21621
+ this.notificationCenter.onObjectEventPersisted(e);
21622
+ }
21623
+ }
20979
21624
  const objectEmbeddingBests = [];
20980
21625
  if (this.objectEmbeddingStore) for (const t of result.tracked) {
20981
21626
  if (!isClipObjectEmbedding(t)) continue;
@@ -21327,19 +21972,28 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21327
21972
  }),
21328
21973
  redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
21329
21974
  onDecision: (decision) => {
21975
+ const meta = {
21976
+ trackId: decision.trackId,
21977
+ reason: decision.reason,
21978
+ className: decision.className,
21979
+ ...decision.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
21980
+ ...decision.bestIncompatibleClass !== void 0 ? {
21981
+ bestIncompatibleClass: decision.bestIncompatibleClass,
21982
+ bestIncompatibleScore: decision.bestIncompatibleScore
21983
+ } : {},
21984
+ minConfidence: config.minConfidence
21985
+ };
21330
21986
  if (!decision.confirmed) this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
21331
21987
  tags: { deviceId },
21332
- meta: {
21333
- trackId: decision.trackId,
21334
- reason: decision.reason
21335
- }
21988
+ meta
21336
21989
  });
21337
21990
  else if (decision.reason !== "confirmed") this.ctx.logger.debug("confirmation gate: birth allowed (fail-open)", {
21338
21991
  tags: { deviceId },
21339
- meta: {
21340
- trackId: decision.trackId,
21341
- reason: decision.reason
21342
- }
21992
+ meta
21993
+ });
21994
+ else this.ctx.logger.info("confirmation gate: birth confirmed", {
21995
+ tags: { deviceId },
21996
+ meta
21343
21997
  });
21344
21998
  }
21345
21999
  });
@@ -21356,6 +22010,48 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21356
22010
  async resolveDeviceStationarySettings(deviceId) {
21357
22011
  return this.stationarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStationarySettings));
21358
22012
  }
22013
+ /**
22014
+ * Count SHORT + MOTIONLESS track closes per frame cell.
22015
+ *
22016
+ * The stationary registry cannot see this class of phantom. Promotion needs a
22017
+ * track that has EXISTED for `PROMOTION_WINDOW_MS` (30 s), and these die long
22018
+ * before: the dead ornamental grass on device 615 produced tracks of 3.8 s,
22019
+ * 4.1 s, 6.5 s and 19.0 s with 0-2.5 px of total displacement, roughly
22020
+ * fifteen of them in a day, all at the same spot. Each one is individually
22021
+ * innocent; the RECURRENCE is the signal, and nothing survives a track death
22022
+ * to notice it.
22023
+ *
22024
+ * Lowering the promotion window is not the fix — those 30 s exist so someone
22025
+ * standing still at a door is not declared scenery.
22026
+ *
22027
+ * This is the measurement half: it establishes how often a cell repeats
22028
+ * before any suppression is built, so "N closes in what window" comes from
22029
+ * data rather than intuition. It suppresses NOTHING.
22030
+ */
22031
+ noteShortMotionlessTrack(track) {
22032
+ const lifeMs = track.lastSeen - track.firstSeen;
22033
+ const moved = track.totalDistance ?? 0;
22034
+ if (lifeMs > SHORT_TRACK_MAX_MS || moved > MOTIONLESS_MAX_PX) return;
22035
+ const first = track.positions?.[0];
22036
+ if (!first) return;
22037
+ const cell = `${Math.round(first.x / PHANTOM_CELL_PX)},${Math.round(first.y / PHANTOM_CELL_PX)}`;
22038
+ const key = `${track.deviceId}:${track.className}:${cell}`;
22039
+ const now = Date.now();
22040
+ const seen = this.shortMotionlessCells.get(key)?.filter((t) => now - t < PHANTOM_CELL_WINDOW_MS) ?? [];
22041
+ seen.push(now);
22042
+ this.shortMotionlessCells.set(key, seen);
22043
+ this.ctx.logger.info("short motionless track closed", {
22044
+ tags: { deviceId: track.deviceId },
22045
+ meta: {
22046
+ trackId: track.trackId,
22047
+ className: track.className,
22048
+ lifeMs,
22049
+ movedPx: Math.round(moved * 10) / 10,
22050
+ cell,
22051
+ repeatsInWindow: seen.length
22052
+ }
22053
+ });
22054
+ }
21359
22055
  stationarySettingsFromCache(deviceId) {
21360
22056
  return this.stationarySettingsCache.peek(deviceId) ?? STATIONARY_DEFAULTS;
21361
22057
  }
@@ -21365,9 +22061,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
21365
22061
  /**
21366
22062
  * Resolve a device's ENABLED `package`-stage zone rules independent of the
21367
22063
  * live frame path (mirrors `resolveDeviceZones`). Warms the proxy and,
21368
- * when the cached slice is empty, forces one refresh. The `package` slice
21369
- * is written by the orchestrator's package-stage provider (a later slice);
21370
- * until then this returns `[]` and no package events fire.
22064
+ * when the cached slice is empty, forces one refresh.
22065
+ *
22066
+ * The `package` slice is written through the `zone-rules` capability
22067
+ * (`zoneRules.setRules({stage:'package'})`), which is live. An earlier
22068
+ * version of this comment claimed the provider did not exist yet and that
22069
+ * "no package events fire" — that was stale, and believing it produced a
22070
+ * confidently wrong diagnosis on 2026-07-30. An empty list here means the
22071
+ * operator has drawn no package zone rule, nothing more.
21371
22072
  */
21372
22073
  async resolveDevicePackageRules(deviceId) {
21373
22074
  const proxy = await this.ensureProxy(deviceId);