@camstack/addon-post-analysis 1.2.217 → 1.2.219

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 VISIT_MERGE_GAP_MS, $t as object, A as NcRulePatchSchema, Bt as CamProfileSchema, C as NC_ALARM_SYSTEM_EVENT_KINDS, D as NC_TAXONOMY, Dt as readDeviceStateFrom, Et as plateGalleryCapability, F as NcSnoozeSchema, Ft as vectorDimFromBase64, G as SCENE_DEFAULT_UNCOVERED_POLICY, Gt as nodePin, H as RetrainStatusSchema, Ht as createEvent, I as NcSnoozeSuppressedSchema, It as videoclipsCapability, J as TIMELAPSE_DENSE_FLOOR_SEC, Jt as array, K as SCENE_DIVERGED, Kt as sleep$1, L as NcSystemEventKindSchema, Lt as zoneAnalyticsCapability, M as NcRuleTargetSchema, Mt as storageOccupancyCapability, N as NcScheduleSchema, Nt as subKindsOf, O as NcConditionDescriptorSchema, Ot as readTimelapseGeneratedAt, P as NcSnoozeInputSchema, Pt as systemEventFilterApplies, Q as TrackSourceSchema, Qt as number, R as NcTaxonomySchema, Rt as errMsg$1, S as MediaFileKindEnum, St as notificationRulesCapability, T as NC_DEFAULT_SNOOZE_MINUTES, Tt as pipelineAnalyticsCapability, Ut as hydrateSchema, V as RECORDING_EXPORT_MAX_READ_BYTES, Vt as DeviceType, W as SCENE_DEFAULT_ANCHOR_THRESHOLD, Wt as isDeviceScopedCap, X as TimelapseRulePatchSchema, Xt as discriminatedUnion, Y as TimelapseRuleInputSchema, Yt as boolean, Z as TimelapseRuleSchema, Zt as literal, _ as LabelAttributionSchema, _t as isDetectionMacroClass, a as CLUSTER_MODEL_SCOPED_STEPS, at as buildEventKindDescriptor, b as MAX_BIRTH_DECISION_RECORDS, bt as kebabToCamel, ct as defineCustomActions, dt as encodeVectorBase64, en as partialRecord, et as addonWidgetsSourceCapability, f as DeclaredDevices, ft as evaluateSensorEdge, g as FailureCounters, h as FULL_IMAGE_BBOX, ht as failureContributionCapability, i as BirthDecisionRecordSchema, in as EventCategory, it as audioModeOf, j as NcRuleSchema, jt as sceneMonitorCapability, k as NcRuleInputSchema, kt as resolveLocationMode, lt as deriveRecordingMode, m as EVENT_PAD_MS, mt as faceGalleryCapability, n as ArchivedDebugNoteSchema, nn as string, nt as assertTimelapseCadences, o as COCO_TO_MACRO, ot as cosineSimilarity$1, p as EVENT_KIND_BY_CAP, pt as evictionPolicyOfLocation, q as SceneMonitorSchema, qt as _enum, r as BaseDevice, rn as unknown, rt as audioMetricsCapability, s as DEFAULT_EVENT_COLOR, st as customAction, t as AUDIO_MACRO_LABELS, tn as record, tt as alarmPanelCapability, u as DETECTION_MACRO_CLASSES, v as MACRO_LABELS, vt as isScheduleActive, w as NC_CONDITION_CATALOG, wt as pickClusterStepModels, xt as mayWriteToLocation, y as MAX_ARCHIVED_DEBUG_NOTES, yt as isSourceCap, z as OpsLogEntrySchema, zt as BaseAddon } from "../dist-DWZqNymj.mjs";
1
+ import { $ as VISIT_MERGE_GAP_MS, $t as object, A as NcRulePatchSchema, Bt as CamProfileSchema, C as NC_ALARM_SYSTEM_EVENT_KINDS, D as NC_TAXONOMY, Dt as readDeviceStateFrom, Et as plateGalleryCapability, F as NcSnoozeSchema, Ft as vectorDimFromBase64, G as SCENE_DEFAULT_UNCOVERED_POLICY, Gt as nodePin, H as RetrainStatusSchema, Ht as createEvent, I as NcSnoozeSuppressedSchema, It as videoclipsCapability, J as TIMELAPSE_DENSE_FLOOR_SEC, Jt as array, K as SCENE_DIVERGED, Kt as sleep$1, L as NcSystemEventKindSchema, Lt as zoneAnalyticsCapability, M as NcRuleTargetSchema, Mt as storageOccupancyCapability, N as NcScheduleSchema, Nt as subKindsOf, O as NcConditionDescriptorSchema, Ot as readTimelapseGeneratedAt, P as NcSnoozeInputSchema, Pt as systemEventFilterApplies, Q as TrackSourceSchema, Qt as number, R as NcTaxonomySchema, Rt as errMsg$1, S as MediaFileKindEnum, St as notificationRulesCapability, T as NC_DEFAULT_SNOOZE_MINUTES, Tt as pipelineAnalyticsCapability, Ut as hydrateSchema, V as RECORDING_EXPORT_MAX_READ_BYTES, Vt as DeviceType, W as SCENE_DEFAULT_ANCHOR_THRESHOLD, Wt as isDeviceScopedCap, X as TimelapseRulePatchSchema, Xt as discriminatedUnion, Y as TimelapseRuleInputSchema, Yt as boolean, Z as TimelapseRuleSchema, Zt as literal, _ as LabelAttributionSchema, _t as isDetectionMacroClass, a as CLUSTER_MODEL_SCOPED_STEPS, at as buildEventKindDescriptor, b as MAX_BIRTH_DECISION_RECORDS, bt as kebabToCamel, ct as defineCustomActions, dt as encodeVectorBase64, en as partialRecord, et as addonWidgetsSourceCapability, f as DeclaredDevices, ft as evaluateSensorEdge, g as FailureCounters, h as FULL_IMAGE_BBOX, ht as failureContributionCapability, i as BirthDecisionRecordSchema, in as EventCategory, it as audioModeOf, j as NcRuleSchema, jt as sceneMonitorCapability, k as NcRuleInputSchema, kt as resolveLocationMode, lt as deriveRecordingMode, m as EVENT_PAD_MS, mt as faceGalleryCapability, n as ArchivedDebugNoteSchema, nn as string, nt as assertTimelapseCadences, o as COCO_TO_MACRO, ot as cosineSimilarity$1, p as EVENT_KIND_BY_CAP, pt as evictionPolicyOfLocation, q as SceneMonitorSchema, qt as _enum, r as BaseDevice, rn as unknown, rt as audioMetricsCapability, s as DEFAULT_EVENT_COLOR, st as customAction, t as AUDIO_MACRO_LABELS, tn as record, tt as alarmPanelCapability, u as DETECTION_MACRO_CLASSES, v as MACRO_LABELS, vt as isScheduleActive, w as NC_CONDITION_CATALOG, wt as pickClusterStepModels, xt as mayWriteToLocation, y as MAX_ARCHIVED_DEBUG_NOTES, yt as isSourceCap, z as OpsLogEntrySchema, zt as BaseAddon } from "../dist-D0l3xZqa.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -8334,8 +8334,55 @@ function incomingFromSystemEvent(event, lookup) {
8334
8334
  default: return null;
8335
8335
  }
8336
8336
  }
8337
+ /** The shells whose update has its own sentence. Anything else gets the plain
8338
+ * one: a body that names a shell nobody observed is worse than a vague body. */
8339
+ var WRAPPER_BODY_KINDS = new Set([
8340
+ "docker",
8341
+ "electron",
8342
+ "native"
8343
+ ]);
8344
+ /**
8345
+ * The SHELL announcement (D437) — the container / app bundle a node runs
8346
+ * inside is behind the release contract.
8347
+ *
8348
+ * It is deliberately NOT folded into `server-update-available`: the versions
8349
+ * here are the shell's (a seed closure, an app bundle), not the running code's,
8350
+ * and on 2026-08-02 the running code was perfectly current on a hub whose image
8351
+ * was 13 days old from a repository nothing rebuilt. It also mints no button —
8352
+ * no process can replace the shell it is running inside — so the BODY is the
8353
+ * whole delivery, and it is chosen by `wrapperKind` so a docker operator is
8354
+ * handed the exact command instead of a button that cannot exist.
8355
+ */
8356
+ function incomingFromWrapperUpdate(facts, data) {
8357
+ const packages = updatePackages(data);
8358
+ if (packages.length === 0) return null;
8359
+ const head = packages[0];
8360
+ const nodeId = head.nodeId ?? stringField(data, "nodeId");
8361
+ const wrapperKind = stringField(data, "wrapperKind");
8362
+ const bodySuffix = wrapperKind !== void 0 && WRAPPER_BODY_KINDS.has(wrapperKind) ? wrapperKind : null;
8363
+ return systemSubject({
8364
+ facts,
8365
+ kind: "wrapper-update-available",
8366
+ subject: `wrapper:${nodeId ?? "hub"}`,
8367
+ titleKey: "system.wrapper-update-available.title",
8368
+ bodyKey: bodySuffix === null ? "system.wrapper-update-available.body" : `system.wrapper-update-available.body.${bodySuffix}`,
8369
+ textParams: {
8370
+ currentVersion: head.currentVersion,
8371
+ latestVersion: head.latestVersion,
8372
+ ...nodeId !== void 0 ? { nodeId } : {}
8373
+ },
8374
+ textCount: 1,
8375
+ packageName: head.packageName,
8376
+ packageNames: [head.packageName],
8377
+ ...nodeId !== void 0 ? {
8378
+ nodeId,
8379
+ nodeIds: [nodeId]
8380
+ } : {}
8381
+ });
8382
+ }
8337
8383
  function incomingFromUpdateAvailable(facts, data) {
8338
8384
  const target = stringField(data, "target");
8385
+ if (target === "wrapper") return incomingFromWrapperUpdate(facts, data);
8339
8386
  if (target !== "addon" && target !== "server") return null;
8340
8387
  const packages = updatePackages(data);
8341
8388
  if (packages.length === 0) return null;
@@ -9445,7 +9492,7 @@ var CAMERA_DEVICE_TYPE = "camera";
9445
9492
  /** Kinds whose action is "go and apply an ADDON update". */
9446
9493
  var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
9447
9494
  /** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
9448
- var NODE_UPDATE_KINDS = new Set(["server-update-available"]);
9495
+ var NODE_UPDATE_KINDS = new Set(["server-update-available", "wrapper-update-available"]);
9449
9496
  /**
9450
9497
  * Kinds whose action is "go and look at this device's stream".
9451
9498
  *
@@ -10010,6 +10057,11 @@ var en_default = {
10010
10057
  "system.server-update-available.body": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}})",
10011
10058
  "system.server-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
10012
10059
  "system.server-update-available.body.onNodes": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on {{nodes}}",
10060
+ "system.wrapper-update-available.title": "The container or app on {{nodeId}} is out of date",
10061
+ "system.wrapper-update-available.body": "The shell this node runs inside carries {{currentVersion}}; the release contract delivers {{latestVersion}}. Updating the server keeps the code current but never replaces the shell.",
10062
+ "system.wrapper-update-available.body.docker": "This node's image carries seed {{currentVersion}}; the release contract delivers {{latestVersion}}. A container cannot replace its own image — recreate it from the contract image: docker compose pull && docker compose up -d",
10063
+ "system.wrapper-update-available.body.electron": "The desktop app on {{nodeId}} carries {{currentVersion}}; the release contract delivers {{latestVersion}}. The app downloads the new build on its own and installs it the next time it quits.",
10064
+ "system.wrapper-update-available.body.native": "The installed app on {{nodeId}} carries {{currentVersion}}; the release contract delivers {{latestVersion}}. Reinstall it from the current release to converge.",
10013
10065
  "system.addon-updated.title": {
10014
10066
  "one": "{{count}} addon updated",
10015
10067
  "other": "{{count}} addons updated"
@@ -10265,6 +10317,11 @@ var it_default = {
10265
10317
  "system.server-update-available.body": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}})",
10266
10318
  "system.server-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
10267
10319
  "system.server-update-available.body.onNodes": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) su {{nodes}}",
10320
+ "system.wrapper-update-available.title": "Il contenitore o l'app di {{nodeId}} è indietro",
10321
+ "system.wrapper-update-available.body": "Il guscio in cui gira questo nodo porta {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. Aggiornare il server tiene il codice aggiornato ma non sostituisce mai il guscio.",
10322
+ "system.wrapper-update-available.body.docker": "L'immagine di questo nodo porta il seed {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. Un container non può sostituire la propria immagine — ricrealo dall'immagine del contratto: docker compose pull && docker compose up -d",
10323
+ "system.wrapper-update-available.body.electron": "L'app desktop su {{nodeId}} porta {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. L'app scarica da sola la nuova build e la installa alla prossima chiusura.",
10324
+ "system.wrapper-update-available.body.native": "L'app installata su {{nodeId}} porta {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. Reinstallala dal rilascio corrente per allinearla.",
10268
10325
  "system.addon-updated.title": {
10269
10326
  "one": "{{count}} addon aggiornato",
10270
10327
  "other": "{{count}} addon aggiornati"
@@ -20070,11 +20127,19 @@ var TimelapseRenderer = class {
20070
20127
  async render(request) {
20071
20128
  const { deviceId, window } = request;
20072
20129
  const tags = { deviceId };
20073
- const availability = await this.ports.getAvailability({
20130
+ const first = await this.ports.getAvailability({
20074
20131
  deviceId,
20075
20132
  fromMs: window.startMs,
20076
20133
  toMs: window.endMs
20077
20134
  });
20135
+ const cheapest = [...first.profilesWithFootage].sort((a, b) => profileCost(a) - profileCost(b))[0];
20136
+ const describes = first.ranges[0]?.profile;
20137
+ const availability = cheapest !== void 0 && cheapest !== describes ? (await this.ports.getAvailability({
20138
+ deviceId,
20139
+ fromMs: window.startMs,
20140
+ toMs: window.endMs,
20141
+ profile: cheapest
20142
+ })).ranges : first.ranges;
20078
20143
  const chosen = pickProfile(availability, window);
20079
20144
  if (chosen === null || chosen.coverage.coveredMs === 0) {
20080
20145
  this.ports.logger.warn("timelapse skipped: no footage covers the window", {
@@ -25794,6 +25859,7 @@ function systemEventSubject(kind, scope) {
25794
25859
  if (kind === "detection-blind") return `detection-blind:${scope.deviceId ?? 0}`;
25795
25860
  if (kind === "addon-crash-loop") return `addon-crash:${scope.nodeId ?? "hub"}:${TEST_CRASHED_RUNNER}`;
25796
25861
  if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
25862
+ if (kind === "wrapper-update-available") return `wrapper:${scope.nodeId ?? "hub"}`;
25797
25863
  if (kind === "addon-updated") return `packages:addon-updated:${scope.packageName ?? "pkg"}`;
25798
25864
  if (kind === "server-updated") return `server-updated:${scope.nodeId ?? "hub"}`;
25799
25865
  if (kind === "export-completed") return `export:${scope.deviceId ?? 0}`;
@@ -33395,7 +33461,12 @@ var FaceGalleryProvider = class {
33395
33461
  identityId: input.identityId
33396
33462
  });
33397
33463
  assigned += 1;
33398
- } catch {
33464
+ } catch (err) {
33465
+ this.logger.error("assignFaces refused a face", { meta: {
33466
+ faceId,
33467
+ identityId: input.identityId,
33468
+ error: err instanceof Error ? `${err.name}: ${err.message}` : String(err)
33469
+ } });
33399
33470
  failed.push(faceId);
33400
33471
  }
33401
33472
  return {
@@ -34777,6 +34848,21 @@ var SpawnBesideCoastingSampler = class {
34777
34848
  this.counts.clear();
34778
34849
  }
34779
34850
  };
34851
+ /** Per-cause counter + sampling decision. One instance per tracker, so the
34852
+ * counts are per camera and per detection source. */
34853
+ var TrackLossSampler = class {
34854
+ counts = /* @__PURE__ */ new Map();
34855
+ /** The running count when this loss should be emitted, else `undefined`. */
34856
+ next(cause) {
34857
+ const count = (this.counts.get(cause) ?? 0) + 1;
34858
+ this.counts.set(cause, count);
34859
+ if (count <= 10) return count;
34860
+ return count % 50 === 0 ? count : void 0;
34861
+ }
34862
+ reset() {
34863
+ this.counts.clear();
34864
+ }
34865
+ };
34780
34866
  /** Per-reason counter + sampling decision. One instance per tracker, so the
34781
34867
  * counts are per camera and per detection source. */
34782
34868
  var SpawnRefusalSampler = class {
@@ -34885,6 +34971,16 @@ var SortTracker = class SortTracker {
34885
34971
  refusalSink = null;
34886
34972
  /** Per-reason counters behind the sampled spawn-beside-coasting diagnostics. */
34887
34973
  spawnBesideSampler = new SpawnBesideCoastingSampler();
34974
+ /**
34975
+ * Deliveries to {@link update} since this tracker was constructed. One per
34976
+ * frame the pipeline actually hands over — which is exactly what "the camera
34977
+ * was looking" means, and is NOT derivable from wall clock: a quiet camera
34978
+ * and a busy one span the same seconds (D438).
34979
+ */
34980
+ frameSeq = 0;
34981
+ /** Sink for track deaths — see {@link setTrackLossObserver}. */
34982
+ trackLossObserver = null;
34983
+ trackLossSampler = new TrackLossSampler();
34888
34984
  /** Sink for fragmentation diagnostics — see {@link setSpawnBesideCoastingObserver}. */
34889
34985
  spawnBesideObserver = null;
34890
34986
  constructor(config = {}) {
@@ -34917,6 +35013,13 @@ var SortTracker = class SortTracker {
34917
35013
  * Same contract as the other two sinks — the tracker has no `deviceId`, the
34918
35014
  * caller stamps it, and nothing is computed when no observer is wired.
34919
35015
  */
35016
+ /**
35017
+ * Wire the sink for {@link TrackLossRecord}. Nothing is computed when no
35018
+ * observer is attached.
35019
+ */
35020
+ setTrackLossObserver(observer) {
35021
+ this.trackLossObserver = observer;
35022
+ }
34920
35023
  setSpawnBesideCoastingObserver(observer) {
34921
35024
  this.spawnBesideObserver = observer;
34922
35025
  }
@@ -34931,13 +35034,13 @@ var SortTracker = class SortTracker {
34931
35034
  reportSpawnBesideCoasting(spawnedId, det, coasting, detections, frameArea, timestamp) {
34932
35035
  const observer = this.spawnBesideObserver;
34933
35036
  if (!observer) return;
34934
- const group = this.assocGroup(det.class);
35037
+ const family = SortTracker.spawnBesideFamily(det.class);
34935
35038
  const dc = bboxCentroid(det.bbox);
34936
35039
  let best;
34937
35040
  let bestDist = Number.POSITIVE_INFINITY;
34938
35041
  let bestReach = 0;
34939
35042
  for (const track of coasting) {
34940
- if (this.assocGroup(track.class) !== group) continue;
35043
+ if (SortTracker.spawnBesideFamily(track.class) !== family) continue;
34941
35044
  const tc = bboxCentroid(track.bbox);
34942
35045
  const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
34943
35046
  const reach = this.reacquireReach(track, timestamp, true);
@@ -34952,6 +35055,7 @@ var SortTracker = class SortTracker {
34952
35055
  const declinedBy = this.explainDecline(best, det, detections, frameArea, timestamp, bestDist);
34953
35056
  const spawnCount = this.spawnBesideSampler.next(declinedBy);
34954
35057
  if (spawnCount === void 0) return;
35058
+ const crowd = declinedBy === "crowd-guard" ? this.crowdAround(best, det, detections) : void 0;
34955
35059
  observer({
34956
35060
  spawnedTrackId: spawnedId,
34957
35061
  spawnedClass: det.class,
@@ -34968,6 +35072,8 @@ var SortTracker = class SortTracker {
34968
35072
  gapMs: timestamp - best.lastSeen,
34969
35073
  trackAgeMs: timestamp - best.firstSeen,
34970
35074
  declinedBy,
35075
+ ...crowd === void 0 ? {} : { competitorCount: crowd.count },
35076
+ ...crowd?.nearestPx === void 0 ? {} : { nearestCompetitorPx: crowd.nearestPx },
34971
35077
  spawnCount,
34972
35078
  sampledEvery: 10
34973
35079
  });
@@ -34982,8 +35088,7 @@ var SortTracker = class SortTracker {
34982
35088
  * `already-claimed` means.
34983
35089
  */
34984
35090
  explainDecline(track, det, detections, frameArea, timestamp, centroidDist) {
34985
- if (!this.sameGroupAsMeasured(track.class, det)) return "native-measured-class";
34986
- if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return "assoc-group";
35091
+ if (track.class !== det.class) return det.nativeRecovery !== void 0 ? "native-measured-class" : "cross-class";
34987
35092
  if (iou$3(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return "already-claimed";
34988
35093
  if (centroidDist <= this.reacquireReach(track, timestamp, true)) {
34989
35094
  if (!this.isPlausibleBirth(det, frameArea)) return "already-claimed";
@@ -35042,14 +35147,44 @@ var SortTracker = class SortTracker {
35042
35147
  * come back — a graveyarded track may still resurrect, and a line about work
35043
35148
  * that was not lost is worse than no line.
35044
35149
  */
35150
+ /**
35151
+ * Report one track that ended with nothing taking over (D438).
35152
+ *
35153
+ * Deliberately separate from {@link reportTrackLoss}, which speaks only for
35154
+ * spawns that never reached `minHits`. The population under investigation —
35155
+ * animal tracks dying mid-frame after six frames — had all EMITTED, so that
35156
+ * path never saw a single one of them.
35157
+ *
35158
+ * `timestamp` is the frame doing the sweeping, not the track's own clock: the
35159
+ * question is how much passed while nobody was looking.
35160
+ */
35161
+ reportTrackDeath(track, cause, timestamp) {
35162
+ const observer = this.trackLossObserver;
35163
+ if (!observer) return;
35164
+ const lossCount = this.trackLossSampler.next(cause);
35165
+ if (lossCount === void 0) return;
35166
+ observer({
35167
+ trackId: track.id,
35168
+ trackClass: track.class,
35169
+ cause,
35170
+ framesSinceLastSeen: this.frameSeq - track.lastSeenFrameSeq,
35171
+ msSinceLastSeen: timestamp - track.lastSeen,
35172
+ lifeMs: track.lastSeen - track.firstSeen,
35173
+ positions: track.hits,
35174
+ emitted: track.emitted,
35175
+ lossCount,
35176
+ sampledEvery: 50
35177
+ });
35178
+ }
35045
35179
  reportTrackLoss(track) {
35046
35180
  if (track.emitted || track.lossReported) return;
35047
35181
  track.lossReported = true;
35048
35182
  this.reportRefusal("unconfirmed-spawn-lost", {
35049
- class: track.class,
35183
+ class: track.frameClass,
35050
35184
  originalClass: track.originalClass,
35051
35185
  score: track.score,
35052
- bbox: track.bbox
35186
+ bbox: track.bbox,
35187
+ ...track.lastNativeRecovery !== void 0 ? { nativeRecovery: track.lastNativeRecovery } : {}
35053
35188
  }, {
35054
35189
  threshold: this.config.minHits,
35055
35190
  measured: track.hits,
@@ -35104,43 +35239,73 @@ var SortTracker = class SortTracker {
35104
35239
  static pairKey(a, b) {
35105
35240
  return a < b ? `${a}|${b}` : `${b}|${a}`;
35106
35241
  }
35107
- /** Association-gating group of a class. When `classGroupAssoc` is on the
35108
- * {person,animal} pair collapses to one group so a per-frame class flip
35109
- * (crouch person animal) re-matches the existing track instead of spawning a
35110
- * concurrent id. Every other class is its own group. */
35111
- assocGroup(cls) {
35112
- if (this.config.classGroupAssoc && (cls === PERSON_CLASS || cls === ANIMAL_CLASS)) return `${PERSON_CLASS}|${ANIMAL_CLASS}`;
35242
+ /** The two macro-classes a coarse pass confuses on a non-upright person. */
35243
+ static isPersonAnimalPair(a, b) {
35244
+ return a === PERSON_CLASS && b === ANIMAL_CLASS || a === ANIMAL_CLASS && b === PERSON_CLASS;
35245
+ }
35246
+ /**
35247
+ * The family a spawn-beside-coasting line is reported AGAINST {person,
35248
+ * animal} as one, every other class its own. Reporting only, never a binding
35249
+ * rule: a cross-class pair is exactly what that line exists to name (the
35250
+ * D378 veto, the D439 salvage rule), so a pair the tracker refused must still
35251
+ * be reportable. Independent of `classGroupAssoc` for the same reason.
35252
+ */
35253
+ static spawnBesideFamily(cls) {
35254
+ if (cls === PERSON_CLASS || cls === ANIMAL_CLASS) return `${PERSON_CLASS}|${ANIMAL_CLASS}`;
35113
35255
  return cls;
35114
35256
  }
35115
- /** Whether a detection may associate to a track under the current class gate:
35116
- * exact class when `classGating` is off (no gate at all) matching the legacy
35117
- * contract — otherwise same association group (group == exact class unless
35118
- * `classGroupAssoc` widened it to {person,animal}). */
35257
+ /**
35258
+ * A CROSS-CLASS BIND IS A FLIP CLAIM, AND A FLIP IS WEAK (D439).
35259
+ *
35260
+ * `classGroupAssoc` exists for one failure of the COARSE full-frame pass: a
35261
+ * bending person re-labelled `animal` for a frame, over the person's own
35262
+ * box, at the 0.4-0.6 the coarse pass gives a shape it half recognises. That
35263
+ * claim has three parts, and this predicate is all three:
35264
+ *
35265
+ * - the box was measured by the coarse pass — never `nativeRecovery`. The
35266
+ * runner's second pass measures the OPPOSITE thing: a 640-800 px native
35267
+ * square around a motion region the full-frame boxes do NOT account for,
35268
+ * which is by construction not the subject the full pass already has.
35269
+ * 2026-09-07 07:51:33, an `animal` at `nativeRecoveryScore=0.833`
35270
+ * absorbed into a person: the D378 half of this rule;
35271
+ * - it is a {person, animal} pair;
35272
+ * - it is WEAK — below {@link TrackerConfig.animalOverPersonMaxScore}, the
35273
+ * same bar the spawn gate uses for the same claim. A `person` at 0.86 or
35274
+ * an `animal` at 0.92 is not a half-recognised shape, it is a subject,
35275
+ * and binding it to the other class's track hands one subject's id to
35276
+ * another (camera 615, 17:11:01: person 0.8616 → animal track
35277
+ * `a28c1557`, which was then born as a person).
35278
+ *
35279
+ * Where the caller may use it is the fourth part, and it is the caller's:
35280
+ * only the predicted-box passes ({@link greedyAssociate}), where "same place"
35281
+ * is measured against a prediction on the strict `iouThreshold`. The salvage
35282
+ * passes ({@link looseMatch}) never cross classes — their whole premise is
35283
+ * weak overlap, and a weak-overlap cross-class bind is a dog beside its
35284
+ * owner, every time.
35285
+ */
35286
+ crossClassFlip(trackClass, det) {
35287
+ if (!this.config.classGroupAssoc) return false;
35288
+ if (det.nativeRecovery !== void 0) return false;
35289
+ if (!SortTracker.isPersonAnimalPair(trackClass, det.class)) return false;
35290
+ return det.score < this.config.animalOverPersonMaxScore;
35291
+ }
35292
+ /** Whether a detection may associate to a track on a PREDICTED-BOX pass:
35293
+ * its own class, or — with `classGroupAssoc` — a weak coarse flip of the
35294
+ * {person, animal} pair ({@link crossClassFlip}). `classGating` off means no
35295
+ * gate at all, matching the legacy contract, except that a native-measured
35296
+ * box still binds only its own class (D378). */
35119
35297
  associable(trackClass, det) {
35120
- if (!this.sameGroupAsMeasured(trackClass, det)) return false;
35298
+ if (trackClass === det.class) return true;
35299
+ if (det.nativeRecovery !== void 0) return false;
35121
35300
  if (!this.config.classGating) return true;
35122
- return this.assocGroup(trackClass) === this.assocGroup(det.class);
35301
+ return this.crossClassFlip(trackClass, det);
35123
35302
  }
35124
35303
  /**
35125
- * A NATIVE-MEASURED BOX IS ITS OWN SUBJECT ACROSS CLASSES (D378).
35126
- *
35127
- * `classGroupAssoc` collapses {person, animal} into one association group for
35128
- * one failure of the COARSE full-frame pass: a bending person re-labelled
35129
- * `animal` for a frame, over the person's own box. Letting that box re-match
35130
- * the person track is strictly better than minting a concurrent id.
35131
- *
35132
- * The runner's second pass makes the opposite measurement — the same detector
35133
- * on a 640-800 px native square around a motion region the full-frame boxes
35134
- * do NOT account for. Such a box is, by construction, not the subject the
35135
- * full pass already has; absorbing it into a person track means the animal is
35136
- * never seen at all, which on 2026-09-07 07:51:33 is exactly what happened to
35137
- * an `animal` at `nativeRecoveryScore=0.833`.
35138
- *
35139
- * So the widening does not apply to a detection carrying `nativeRecovery`:
35140
- * it associates to its OWN class or it spawns. Every other guard on that
35141
- * spawn still runs — the per-class score floor, the area floor, the
35142
- * `dedupSpawnIou` overlap, and `resolveDuplicates` afterwards, where a person
35143
- * still beats a cross-class animal.
35304
+ * A NATIVE-MEASURED BOX IS ITS OWN SUBJECT ACROSS CLASSES (D378) — the
35305
+ * occlusion gate's copy of the first clause of {@link crossClassFlip}. A
35306
+ * cross-class occluder is not a same-subject claim for a box the native
35307
+ * pass measured: a dog at its owner's knee is 100% contained in the person's
35308
+ * box and is not the person.
35144
35309
  */
35145
35310
  sameGroupAsMeasured(trackClass, det) {
35146
35311
  if (det.nativeRecovery === void 0) return true;
@@ -35313,9 +35478,16 @@ var SortTracker = class SortTracker {
35313
35478
  };
35314
35479
  }
35315
35480
  /**
35316
- * How many same-association-group subjects OTHER than `corpse` share the
35317
- * scene with it: every track LIVE on this frame, plus any other resurrectable
35318
- * corpse whose lifetime OVERLAPPED the candidate's.
35481
+ * How many SAME-CLASS subjects OTHER than `corpse` share the scene with it:
35482
+ * every track LIVE on this frame, plus any other resurrectable corpse whose
35483
+ * lifetime OVERLAPPED the candidate's.
35484
+ *
35485
+ * Exact class, not the {person, animal} family (D439): a competitor is a
35486
+ * subject the id could actually be handed to, and since a salvage pass binds
35487
+ * its own class only, a person cannot take a dog's id there. Counting the
35488
+ * owner as the dog's competitor split one dog's id 12 times on camera 615 in
35489
+ * 6.6 h — every bind the reach clause accepted, the guard then refused
35490
+ * because the person was on screen.
35319
35491
  *
35320
35492
  * The overlap clause on the graveyard half is what keeps this guard off the
35321
35493
  * case the resurrection pass exists for. A single flickering subject leaves a
@@ -35329,30 +35501,30 @@ var SortTracker = class SortTracker {
35329
35501
  * steals an id is very often one whose OWN track was born after the corpse
35330
35502
  * died (camera 618 — the toddler's track post-dates the older boy's).
35331
35503
  */
35332
- sameGroupPeerCount(corpse, live) {
35333
- const group = this.assocGroup(corpse.class);
35504
+ sameClassPeerCount(corpse, live) {
35334
35505
  let peers = 0;
35335
- for (const t of live) if (t !== corpse && this.assocGroup(t.class) === group) peers++;
35506
+ for (const t of live) if (t !== corpse && t.class === corpse.class) peers++;
35336
35507
  for (const other of this.lostTracks) {
35337
35508
  if (other === corpse) continue;
35338
35509
  if (!other.resurrectable) continue;
35339
- if (this.assocGroup(other.class) !== group) continue;
35510
+ if (other.class !== corpse.class) continue;
35340
35511
  if (other.firstSeen <= corpse.lastSeen && corpse.firstSeen <= other.lastSeen) peers++;
35341
35512
  }
35342
35513
  return peers;
35343
35514
  }
35344
35515
  /**
35345
- * Loose same-group gate used by the rescue and resurrection passes: accept
35346
- * when the detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or
35347
- * its centroid is within `rescueCentroidFactor × bbox-diagonal`. Always group-
35348
- * gated (a rescue must never cross association groups) independent of
35349
- * `classGating`, so a coasting person track can be rescued by its own
35350
- * crouch→animal flip when `classGroupAssoc` is on, but never by an unrelated
35351
- * class.
35516
+ * Loose gate used by the rescue and resurrection passes: accept when the
35517
+ * detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or its
35518
+ * centroid is within reach. EXACT-class, always, independent of
35519
+ * `classGating` and of `classGroupAssoc` (D439): a salvage pass binds on
35520
+ * weak overlap by definition, and a weak-overlap bind across {person,
35521
+ * animal} is not a crouch flip a flip sits on the subject's own box — it
35522
+ * is a dog beside its owner, or the owner beside the dog. Until 2026-09-10
35523
+ * this gate crossed the group, and it is where a `person` at 0.86 took over
35524
+ * an animal's id (camera 615, 17:11:01).
35352
35525
  */
35353
35526
  looseMatch(track, det, timestamp, timeScaled = true) {
35354
- if (!this.sameGroupAsMeasured(track.class, det)) return null;
35355
- if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return null;
35527
+ if (track.class !== det.class) return null;
35356
35528
  if (iou$3(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return "iou";
35357
35529
  const tc = bboxCentroid(track.bbox);
35358
35530
  const dc = bboxCentroid(det.bbox);
@@ -35398,12 +35570,41 @@ var SortTracker = class SortTracker {
35398
35570
  * bind time and at report time — the spawn-beside-coasting line has to be able
35399
35571
  * to say which gate declined without re-running the greedy competition.
35400
35572
  */
35573
+ /**
35574
+ * WHO the crowd was, for the record only — this changes no decision.
35575
+ *
35576
+ * {@link isUnambiguousReach} refuses on PRESENCE, with no distance in the
35577
+ * question, so a person at the far corner of a room splits a track as surely
35578
+ * as one standing beside it. Counting them, and measuring the nearest, is
35579
+ * what makes narrowing that rule a decision rather than a guess (D436).
35580
+ */
35581
+ crowdAround(track, det, detections) {
35582
+ const cx = det.bbox.x + det.bbox.w / 2;
35583
+ const cy = det.bbox.y + det.bbox.h / 2;
35584
+ const counted = [];
35585
+ let nearest;
35586
+ const consider = (bbox) => {
35587
+ counted.push(bbox);
35588
+ const d = Math.hypot(bbox.x + bbox.w / 2 - cx, bbox.y + bbox.h / 2 - cy);
35589
+ if (nearest === void 0 || d < nearest) nearest = d;
35590
+ };
35591
+ for (const t of this.tracks) if (t !== track && t.class === track.class) consider(t.bbox);
35592
+ for (const other of detections) {
35593
+ if (other === det) continue;
35594
+ if (other.class !== track.class) continue;
35595
+ if (counted.some((b) => iou$3(b, other.bbox) > 0)) continue;
35596
+ consider(other.bbox);
35597
+ }
35598
+ return {
35599
+ count: counted.length,
35600
+ nearestPx: nearest
35601
+ };
35602
+ }
35401
35603
  isUnambiguousReach(track, det, detections) {
35402
- if (this.sameGroupPeerCount(track, this.tracks) > 0) return false;
35403
- const group = this.assocGroup(track.class);
35604
+ if (this.sameClassPeerCount(track, this.tracks) > 0) return false;
35404
35605
  for (const other of detections) {
35405
35606
  if (other === det) continue;
35406
- if (this.assocGroup(other.class) === group) return false;
35607
+ if (other.class === track.class) return false;
35407
35608
  }
35408
35609
  return true;
35409
35610
  }
@@ -35440,8 +35641,8 @@ var SortTracker = class SortTracker {
35440
35641
  * weight; otherwise the latest-frame class (ambiguous vote or voting off).
35441
35642
  */
35442
35643
  resolveReportedClass(t) {
35443
- if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.class;
35444
- let bestClass = t.class;
35644
+ if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.frameClass;
35645
+ let bestClass = t.frameClass;
35445
35646
  let bestVote = -1;
35446
35647
  let total = 0;
35447
35648
  for (const [cls, v] of t.classVotes) {
@@ -35451,8 +35652,8 @@ var SortTracker = class SortTracker {
35451
35652
  bestClass = cls;
35452
35653
  }
35453
35654
  }
35454
- if (total <= 0) return t.class;
35455
- return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.class;
35655
+ if (total <= 0) return t.frameClass;
35656
+ return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.frameClass;
35456
35657
  }
35457
35658
  /** Whether a detection clears its per-class spawn score floor. Only gates NEW
35458
35659
  * spawns — an established track still matches below its floor. */
@@ -35468,16 +35669,67 @@ var SortTracker = class SortTracker {
35468
35669
  return this.config.confirmBypassEnabled && t.score >= this.config.confirmBypassScore;
35469
35670
  }
35470
35671
  /**
35672
+ * The live tracks split into the two tiers that claim a box in a fixed
35673
+ * order (D442, see {@link update}): those already confirmed for emission,
35674
+ * and the provisional ones still under `minHits`. Decided on the state at
35675
+ * the start of the frame, as ByteTrack does. With `minHits: 1` the second
35676
+ * tier is always empty and every pass over it is a no-op.
35677
+ */
35678
+ associationTiers() {
35679
+ const established = [];
35680
+ const provisional = [];
35681
+ for (const track of this.tracks) if (this.isConfirmedForEmit(track)) established.push(track);
35682
+ else provisional.push(track);
35683
+ return [established, provisional];
35684
+ }
35685
+ /**
35686
+ * The rescue pass. HIGH-score detections the greedy pass left unmatched get
35687
+ * a second, looser chance against the still-unmatched (coasting) tracks of
35688
+ * `tracks` — measured on the track's LAST-KNOWN bbox, never the drifted
35689
+ * prediction. Recovers half-box re-acquisition and small-drift flicker
35690
+ * without spawning a duplicate. Best (highest last-known IoU) pairs first;
35691
+ * LOW detections are reserved for the ByteTrack low stage.
35692
+ */
35693
+ rescueAssociate(tracks, gates, detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly) {
35694
+ const rescuePairs = [];
35695
+ for (const track of tracks) {
35696
+ if (matchedTracks.has(track)) continue;
35697
+ for (let di = 0; di < detections.length; di++) {
35698
+ if (used.has(di) || lowSet.has(di) || continuationOnly.has(di)) continue;
35699
+ const det = detections[di];
35700
+ const gate = this.looseMatch(track, det, timestamp);
35701
+ if (gate === null || !gates.has(gate)) continue;
35702
+ if (gate === "centroid" && this.isPlausibleBirth(det, frameArea) && !(this.config.reacquireTimeScaled && this.isUnambiguousReach(track, det, detections))) continue;
35703
+ rescuePairs.push({
35704
+ track,
35705
+ detIdx: di,
35706
+ score: iou$3(track.bbox, det.bbox),
35707
+ gate
35708
+ });
35709
+ }
35710
+ }
35711
+ rescuePairs.sort((a, b) => b.score - a.score);
35712
+ for (const pair of rescuePairs) {
35713
+ if (matchedTracks.has(pair.track) || used.has(pair.detIdx)) continue;
35714
+ const det = detections[pair.detIdx];
35715
+ matched.set(pair.track, det);
35716
+ matchedTracks.add(pair.track);
35717
+ used.add(pair.detIdx);
35718
+ this.reportBind("rescue", pair.gate, pair.track, det, timestamp);
35719
+ }
35720
+ }
35721
+ /**
35471
35722
  * One greedy IoU association pass over a candidate detection subset against the
35472
- * currently-UNMATCHED tracks, on each track's PREDICTED box. Highest-IoU pairs
35723
+ * still-UNMATCHED tracks of `tracks` (one association tier see
35724
+ * {@link associationTiers}), on each track's PREDICTED box. Highest-IoU pairs
35473
35725
  * bind first; a track/detection binds at most once. Shared by the high-score
35474
35726
  * first stage and the low-score second stage (different candidate sets + IoU
35475
35727
  * gates) so the two-tier ByteTrack association stays a single implementation.
35476
35728
  * Mutates `used` / `matchedTracks` / `matched` in place.
35477
35729
  */
35478
- greedyAssociate(detections, candidateIdxs, iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly) {
35730
+ greedyAssociate(tracks, detections, candidateIdxs, iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly) {
35479
35731
  const pairs = [];
35480
- for (const track of this.tracks) {
35732
+ for (const track of tracks) {
35481
35733
  if (matchedTracks.has(track)) continue;
35482
35734
  const pbox = this.predicted(track, timestamp);
35483
35735
  const confirmed = this.isConfirmedForEmit(track);
@@ -35503,6 +35755,7 @@ var SortTracker = class SortTracker {
35503
35755
  }
35504
35756
  }
35505
35757
  update(detections, timestamp, frameContext) {
35758
+ this.frameSeq++;
35506
35759
  const frameArea = frameContext ? frameContext.frameWidth * frameContext.frameHeight : 0;
35507
35760
  const continuationOnly = frameContext?.continuationOnly ?? EMPTY_INDEX_SET;
35508
35761
  const resurrectedScene = /* @__PURE__ */ new Map();
@@ -35513,6 +35766,7 @@ var SortTracker = class SortTracker {
35513
35766
  track.lostAt = timestamp;
35514
35767
  track.resurrectable = false;
35515
35768
  this.reportTrackLoss(track);
35769
+ this.reportTrackDeath(track, "lifetime-cap", timestamp);
35516
35770
  this.lostTracks.push(track);
35517
35771
  } else alive.push(track);
35518
35772
  this.tracks = alive;
@@ -35525,13 +35779,17 @@ var SortTracker = class SortTracker {
35525
35779
  track.lostAt = track.lastSeen;
35526
35780
  track.resurrectable = false;
35527
35781
  this.reportTrackLoss(track);
35782
+ this.reportTrackDeath(track, "stale-beyond-budgets", timestamp);
35528
35783
  this.lostTracks.push(track);
35529
35784
  } else alive.push(track);
35530
35785
  this.tracks = alive;
35531
35786
  }
35532
35787
  const stillResurrectable = [];
35533
35788
  for (const t of this.lostTracks) if (timestamp - t.lostAt <= this.config.resurrectionWindowMs) stillResurrectable.push(t);
35534
- else this.reportTrackLoss(t);
35789
+ else {
35790
+ this.reportTrackLoss(t);
35791
+ this.reportTrackDeath(t, "graveyard-expired", timestamp);
35792
+ }
35535
35793
  this.lostTracks = stillResurrectable;
35536
35794
  const used = /* @__PURE__ */ new Set();
35537
35795
  const matchedTracks = /* @__PURE__ */ new Set();
@@ -35541,36 +35799,21 @@ var SortTracker = class SortTracker {
35541
35799
  for (let di = 0; di < detections.length; di++) if (this.config.byteTrackEnabled && detections[di].score < this.config.byteTrackHighThreshold) lowIdxs.push(di);
35542
35800
  else highIdxs.push(di);
35543
35801
  const lowSet = new Set(lowIdxs);
35544
- this.greedyAssociate(detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35545
- const rescuePairs = [];
35546
- for (const track of this.tracks) {
35547
- if (matchedTracks.has(track)) continue;
35548
- for (let di = 0; di < detections.length; di++) {
35549
- if (used.has(di) || lowSet.has(di) || continuationOnly.has(di)) continue;
35550
- const det = detections[di];
35551
- const gate = this.looseMatch(track, det, timestamp);
35552
- if (gate === null) continue;
35553
- if (gate === "centroid" && this.isPlausibleBirth(det, frameArea) && !(this.config.reacquireTimeScaled && this.isUnambiguousReach(track, det, detections))) continue;
35554
- rescuePairs.push({
35555
- track,
35556
- detIdx: di,
35557
- score: iou$3(track.bbox, det.bbox),
35558
- gate
35559
- });
35560
- }
35561
- }
35562
- rescuePairs.sort((a, b) => b.score - a.score);
35563
- for (const pair of rescuePairs) {
35564
- if (matchedTracks.has(pair.track) || used.has(pair.detIdx)) continue;
35565
- const det = detections[pair.detIdx];
35566
- matched.set(pair.track, det);
35567
- matchedTracks.add(pair.track);
35568
- used.add(pair.detIdx);
35569
- this.reportBind("rescue", pair.gate, pair.track, det, timestamp);
35570
- }
35571
- if (this.config.byteTrackEnabled && lowIdxs.length > 0) this.greedyAssociate(detections, lowIdxs, this.config.byteTrackLowIouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35802
+ const [established, provisional] = this.associationTiers();
35803
+ const OVERLAP = new Set(["iou"]);
35804
+ const PROXIMITY = new Set(["centroid", "growth"]);
35805
+ this.greedyAssociate(established, detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35806
+ this.rescueAssociate(established, OVERLAP, detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly);
35807
+ this.greedyAssociate(provisional, detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35808
+ this.rescueAssociate(established, PROXIMITY, detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly);
35809
+ this.rescueAssociate(provisional, new Set([...OVERLAP, ...PROXIMITY]), detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly);
35810
+ if (this.config.byteTrackEnabled && lowIdxs.length > 0) for (const tier of [established, provisional]) this.greedyAssociate(tier, detections, lowIdxs, this.config.byteTrackLowIouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
35572
35811
  for (const [track, det] of matched) {
35573
- if (track.class !== det.class) this.reportRefusal("cross-class-absorbed", det, { against: track });
35812
+ if (track.class !== det.class) this.reportRefusal("cross-class-absorbed", det, {
35813
+ against: track,
35814
+ measured: iou$3(this.predicted(track, timestamp), det.bbox),
35815
+ threshold: this.config.animalOverPersonMaxScore
35816
+ });
35574
35817
  const prevCenter = bboxCentroid({
35575
35818
  x: track.bbox.x,
35576
35819
  y: track.bbox.y,
@@ -35586,12 +35829,15 @@ var SortTracker = class SortTracker {
35586
35829
  const frames = track.age + 1;
35587
35830
  const dtMs = timestamp - track.lastSeen;
35588
35831
  track.bbox = det.bbox;
35589
- track.class = det.class;
35832
+ if (!this.crossClassFlip(track.class, det)) track.class = det.class;
35833
+ track.frameClass = det.class;
35590
35834
  track.originalClass = det.originalClass;
35591
35835
  track.score = det.score;
35836
+ track.lastNativeRecovery = det.nativeRecovery;
35592
35837
  track.age = 0;
35593
35838
  track.hits++;
35594
35839
  track.lastSeen = timestamp;
35840
+ track.lastSeenFrameSeq = this.frameSeq;
35595
35841
  if (dtMs > 0) {
35596
35842
  track.velocity = {
35597
35843
  dx: (newCenter.x - prevCenter.x) / dtMs,
@@ -35640,7 +35886,7 @@ var SortTracker = class SortTracker {
35640
35886
  if (gate === null) continue;
35641
35887
  if (gate === "centroid" && this.isPlausibleBirth(det, frameArea)) continue;
35642
35888
  const score = iou$3(lost.bbox, det.bbox);
35643
- const peers = this.sameGroupPeerCount(lost, surviving);
35889
+ const peers = this.sameClassPeerCount(lost, surviving);
35644
35890
  if (this.config.resurrectionCrowdGuard && peers > 0 && score < this.config.resurrectionCrowdedIouThreshold) {
35645
35891
  if (score > refusedScore) {
35646
35892
  refused = lost;
@@ -35665,12 +35911,15 @@ var SortTracker = class SortTracker {
35665
35911
  this.lostTracks = this.lostTracks.filter((t) => t !== best);
35666
35912
  best.bbox = det.bbox;
35667
35913
  best.class = det.class;
35914
+ best.frameClass = det.class;
35668
35915
  best.originalClass = det.originalClass;
35669
35916
  best.score = det.score;
35917
+ best.lastNativeRecovery = det.nativeRecovery;
35670
35918
  best.age = 0;
35671
35919
  best.hits++;
35672
35920
  best.lost = false;
35673
35921
  best.lastSeen = timestamp;
35922
+ best.lastSeenFrameSeq = this.frameSeq;
35674
35923
  best.velocity = {
35675
35924
  dx: 0,
35676
35925
  dy: 0
@@ -35727,6 +35976,7 @@ var SortTracker = class SortTracker {
35727
35976
  id: spawnedId,
35728
35977
  bbox: det.bbox,
35729
35978
  class: det.class,
35979
+ frameClass: det.class,
35730
35980
  originalClass: det.originalClass,
35731
35981
  score: det.score,
35732
35982
  age: 0,
@@ -35734,6 +35984,7 @@ var SortTracker = class SortTracker {
35734
35984
  path: [det.bbox],
35735
35985
  firstSeen: timestamp,
35736
35986
  lastSeen: timestamp,
35987
+ lastSeenFrameSeq: this.frameSeq,
35737
35988
  velocity: {
35738
35989
  dx: 0,
35739
35990
  dy: 0
@@ -35744,7 +35995,8 @@ var SortTracker = class SortTracker {
35744
35995
  resurrectable: true,
35745
35996
  classVotes: new Map([[det.class, det.score > 0 ? det.score : Number.EPSILON]]),
35746
35997
  emitted: false,
35747
- lossReported: false
35998
+ lossReported: false,
35999
+ lastNativeRecovery: det.nativeRecovery
35748
36000
  });
35749
36001
  }
35750
36002
  this.tracks = surviving;
@@ -36137,7 +36389,6 @@ var FrameProcessor = class {
36137
36389
  * filter). Shares the tracker's vocabulary — see {@link SpawnRefusalReason}. */
36138
36390
  spawnRefusalObserver = null;
36139
36391
  /** Where a collected refusal is forwarded for LOGGING, when anyone asked. */
36140
- spawnRefusalForward = null;
36141
36392
  /**
36142
36393
  * The spawn refusals of the frame being processed.
36143
36394
  *
@@ -36151,6 +36402,10 @@ var FrameProcessor = class {
36151
36402
  /** Running count of refusals-in-zone SEEN — never the throttled number. */
36152
36403
  zoneRefusalEventCount = 0;
36153
36404
  zoneRefusalObserver = null;
36405
+ /** Track ids whose BIRTH zone history has already been folded in — the fold
36406
+ * happens once, on the frame a track first becomes visible. */
36407
+ birthZonesSeeded = /* @__PURE__ */ new Set();
36408
+ birthZoneTrailObserver = null;
36154
36409
  /** Last report time per collapsed key, for {@link ZONE_REFUSAL_THROTTLE_MS}. */
36155
36410
  zoneRefusalLastReport = /* @__PURE__ */ new Map();
36156
36411
  zoneRefusalSampler = new SpawnRefusalSampler();
@@ -36261,12 +36516,20 @@ var FrameProcessor = class {
36261
36516
  setZoneRefusalObserver(observer) {
36262
36517
  this.zoneRefusalObserver = observer;
36263
36518
  }
36519
+ /** Where "this track was born already carrying a zone" is reported. */
36520
+ setBirthZoneTrailObserver(observer) {
36521
+ this.birthZoneTrailObserver = observer;
36522
+ }
36264
36523
  setBindObserver(observer) {
36265
36524
  this.tracker.setBindObserver(observer);
36266
36525
  }
36267
36526
  /** Wire (or clear) the tracker's fragmentation sink — a new id minted beside a
36268
36527
  * live coasting track that could have claimed it (D399). Same `deviceId`
36269
36528
  * contract as the two sinks above. */
36529
+ /** Wire the sink for track deaths (D438) — pure passthrough to the tracker. */
36530
+ setTrackLossObserver(observer) {
36531
+ this.tracker.setTrackLossObserver(observer);
36532
+ }
36270
36533
  setSpawnBesideCoastingObserver(observer) {
36271
36534
  this.tracker.setSpawnBesideCoastingObserver(observer);
36272
36535
  }
@@ -36350,8 +36613,8 @@ var FrameProcessor = class {
36350
36613
  memberships.map((m) => m.zoneId).join("+"),
36351
36614
  cell(r.bbox.x),
36352
36615
  cell(r.bbox.y),
36353
- cell(r.bbox.width),
36354
- cell(r.bbox.height)
36616
+ cell(r.bbox.w),
36617
+ cell(r.bbox.h)
36355
36618
  ].join("|");
36356
36619
  const last = this.zoneRefusalLastReport.get(key);
36357
36620
  if (last !== void 0 && timestamp - last < ZONE_REFUSAL_THROTTLE_MS) return false;
@@ -36596,6 +36859,32 @@ var FrameProcessor = class {
36596
36859
  if (memberships.length > 0) overlapsByTrack.set(td.trackId, memberships);
36597
36860
  if (belowBar.length > 0) rejectedByTrack.set(td.trackId, belowBar);
36598
36861
  }
36862
+ for (const td of trackedDetections) {
36863
+ if (this.birthZonesSeeded.has(td.trackId)) continue;
36864
+ this.birthZonesSeeded.add(td.trackId);
36865
+ const trail = td.path;
36866
+ if (trail.length <= 1) continue;
36867
+ const atBirth = zonesByTrack.get(td.trackId) ?? [];
36868
+ const zones = new Set(atBirth);
36869
+ const bornWith = zones.size;
36870
+ for (let i = 0; i < trail.length - 1; i++) {
36871
+ const box = trail[i];
36872
+ if (box === void 0) continue;
36873
+ const { memberships } = this.zoneEngine.splitDetectionZones(box, this.zones, frameWidth, frameHeight, void 0, void 0, void 0, this.zoneMembershipMinOverlap);
36874
+ for (const m of memberships) zones.add(m.zoneId);
36875
+ }
36876
+ if (zones.size === bornWith) continue;
36877
+ const inherited = [...zones].filter((z) => !atBirth.includes(z));
36878
+ zonesByTrack.set(td.trackId, [...zones]);
36879
+ this.birthZoneTrailObserver?.({
36880
+ deviceId: this.deviceId,
36881
+ trackId: td.trackId,
36882
+ className: td.class,
36883
+ measurementsBeforeBirth: trail.length - 1,
36884
+ zonesAtBirth: [...atBirth],
36885
+ inheritedZones: inherited
36886
+ });
36887
+ }
36599
36888
  this.lastZoneOverlaps = overlapsByTrack;
36600
36889
  this.lastZoneRejections = rejectedByTrack;
36601
36890
  for (const td of trackedDetections) this.lastDetectionByTrack.set(td.trackId, td);
@@ -36668,6 +36957,7 @@ var FrameProcessor = class {
36668
36957
  for (const state of objectStates) {
36669
36958
  if (state.state !== "leaving") continue;
36670
36959
  this.lastZonesByTrack.delete(state.trackId);
36960
+ this.birthZonesSeeded.delete(state.trackId);
36671
36961
  this.lastDetectionByTrack.delete(state.trackId);
36672
36962
  this.reportedRefusalByTrack.delete(state.trackId);
36673
36963
  }
@@ -39595,6 +39885,62 @@ function tieredLabelColumnData(patch) {
39595
39885
  };
39596
39886
  }
39597
39887
  //#endregion
39888
+ //#region src/pipeline-analytics/store/audio-level-density.ts
39889
+ /**
39890
+ * The energy-weighted mean of a set of dBFS values, in dBFS.
39891
+ *
39892
+ * dBFS is logarithmic, so an arithmetic mean of dB numbers is the mean of
39893
+ * nothing physical — it under-weights the loud moment that is the reason to
39894
+ * look at a bar at all. Averaging POWER and converting back is what a meter is
39895
+ * expected to show, and it is the same arithmetic as an RMS over the interval.
39896
+ *
39897
+ * `undefined` for an empty set, and for a set with no finite sample: a bucket
39898
+ * nobody measured must not be reported as silence, and −∞ dBFS is not a number
39899
+ * a bar can paint.
39900
+ */
39901
+ function energyWeightedMeanDbfs(samples) {
39902
+ let power = 0;
39903
+ let n = 0;
39904
+ for (const dbfs of samples) {
39905
+ if (Number.isNaN(dbfs)) continue;
39906
+ if (dbfs === Number.NEGATIVE_INFINITY) {
39907
+ n += 1;
39908
+ continue;
39909
+ }
39910
+ if (!Number.isFinite(dbfs)) continue;
39911
+ power += 10 ** (dbfs / 10);
39912
+ n += 1;
39913
+ }
39914
+ if (n === 0) return void 0;
39915
+ const mean = power / n;
39916
+ if (mean <= 0) return void 0;
39917
+ return 10 * Math.log10(mean);
39918
+ }
39919
+ /**
39920
+ * Energy-weighted mean dBFS per bucket index, for the buckets that HAVE audio.
39921
+ *
39922
+ * A bucket with no events is absent from the map rather than present at zero:
39923
+ * "nothing was heard" and "nothing was measured" are different claims, and the
39924
+ * lane has to be able to draw nothing.
39925
+ */
39926
+ function audioDbfsByBucket(events, since, bucketMs) {
39927
+ const out = /* @__PURE__ */ new Map();
39928
+ if (bucketMs <= 0) return out;
39929
+ const byBucket = /* @__PURE__ */ new Map();
39930
+ for (const ev of events) {
39931
+ const idx = Math.floor((ev.timestamp - since) / bucketMs);
39932
+ if (idx < 0) continue;
39933
+ const bin = byBucket.get(idx);
39934
+ if (bin === void 0) byBucket.set(idx, [ev.dbfs]);
39935
+ else bin.push(ev.dbfs);
39936
+ }
39937
+ for (const [idx, samples] of byBucket) {
39938
+ const mean = energyWeightedMeanDbfs(samples);
39939
+ if (mean !== void 0) out.set(idx, mean);
39940
+ }
39941
+ return out;
39942
+ }
39943
+ //#endregion
39598
39944
  //#region src/pipeline-analytics/store/event-store.ts
39599
39945
  /**
39600
39946
  * @durable class=ledger owner=pipeline-analytics
@@ -40433,10 +40779,11 @@ var EventStore = class {
40433
40779
  });
40434
40780
  return new Map(rows.map((r) => [r.bucket, r.count]));
40435
40781
  };
40436
- const [motionMap, objectMap, audioMap] = await Promise.all([
40782
+ const [motionMap, objectMap, audioMap, audioDbfsMap] = await Promise.all([
40437
40783
  run(MOTION_EVENTS_COLLECTION),
40438
40784
  run(OBJECT_EVENTS_COLLECTION),
40439
- run(AUDIO_EVENTS_COLLECTION)
40785
+ run(AUDIO_EVENTS_COLLECTION),
40786
+ this.audioDbfsPerBucket(q)
40440
40787
  ]);
40441
40788
  const buckets = Math.max(0, Math.ceil((q.until - q.since) / q.bucketMs));
40442
40789
  const out = [];
@@ -40445,16 +40792,46 @@ var EventStore = class {
40445
40792
  const o = objectMap.get(i) ?? 0;
40446
40793
  const a = audioMap.get(i) ?? 0;
40447
40794
  if (m + o + a === 0) continue;
40795
+ const dbfs = audioDbfsMap.get(i);
40448
40796
  out.push({
40449
40797
  bucketStart: q.since + i * q.bucketMs,
40450
40798
  motion: m,
40451
40799
  object: o,
40452
- audio: a
40800
+ audio: a,
40801
+ ...dbfs !== void 0 ? { audioDbfs: dbfs } : {}
40453
40802
  });
40454
40803
  }
40455
40804
  return out;
40456
40805
  }
40457
40806
  /**
40807
+ * Energy-weighted mean dBFS per bucket index, for the buckets that have
40808
+ * audio. Reads the window's audio events once, `slim`, and folds them —
40809
+ * `histogram` can only count, and a level is not a count.
40810
+ *
40811
+ * A read that fails yields an EMPTY map, so the lane draws no level rather
40812
+ * than a wrong one: the density counts beside it are unaffected, and a
40813
+ * missing bar is the honest rendering of "not measured" (D431).
40814
+ */
40815
+ async audioDbfsPerBucket(q) {
40816
+ try {
40817
+ return audioDbfsByBucket((await this.queryAudio({
40818
+ deviceId: q.deviceId,
40819
+ since: q.since,
40820
+ until: q.until,
40821
+ projection: "slim"
40822
+ })).map((e) => ({
40823
+ timestamp: e.timestamp,
40824
+ dbfs: e.dbfs
40825
+ })), q.since, q.bucketMs);
40826
+ } catch (err) {
40827
+ this.logger.warn("density: audio level aggregation failed — the lane will draw no level", {
40828
+ tags: { deviceId: q.deviceId },
40829
+ meta: { error: String(err) }
40830
+ });
40831
+ return /* @__PURE__ */ new Map();
40832
+ }
40833
+ }
40834
+ /**
40458
40835
  * Age-evict the TRACKLESS event kinds — motion and audio.
40459
40836
  *
40460
40837
  * **Object events are not here, and that is the point.** An object event is a
@@ -66493,10 +66870,12 @@ var TrackingSettingsSchema = object({
66493
66870
  minSpawnAreaFracAnimal: number().min(0).max(1).default(0),
66494
66871
  /** Min spawn bbox area for `vehicle` as a fraction of the frame (0 = ungated). */
66495
66872
  minSpawnAreaFracVehicle: number().min(0).max(1).default(0),
66496
- /** Gate association on a class GROUP ({person,animal}) instead of the exact
66497
- * per-frame class, so a person that flips to `animal` for a frame re-matches
66498
- * its existing track (no concurrent animal id). Reported label stays via the
66499
- * class vote. Off = exact per-frame class gating. */
66873
+ /** Let a person that flips to `animal` for a frame re-match its existing
66874
+ * track (no concurrent animal id). A flip is the same subject in the same
66875
+ * place, weakly read (D439): the bind is allowed only on the predicted-box
66876
+ * passes, only for a coarse box below `animalOverPersonMaxScore`, never on
66877
+ * the rescue / resurrection passes. Reported label stays via the class
66878
+ * vote. Off = exact per-frame class gating. */
66500
66879
  classGroupAssoc: boolean().default(true)
66501
66880
  });
66502
66881
  var TRACKING_DEFAULTS = TrackingSettingsSchema.parse({});
@@ -67086,7 +67465,7 @@ function buildDetectionSettingsSections() {
67086
67465
  type: "boolean",
67087
67466
  key: "classGroupAssoc",
67088
67467
  label: "Person/animal group matching",
67089
- 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.",
67468
+ 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. A flip is the same subject in the same place, weakly read: the cross-class match happens only on the strict predicted-box pass and only for a full-frame box below the \"animal over person max score\" bar — a confident animal beside a person, or a person beside an animal, is its own subject and keeps its own track. The reported class is still decided by the per-track class vote.",
67090
67469
  default: TRACKING_DEFAULTS.classGroupAssoc
67091
67470
  },
67092
67471
  {
@@ -72742,12 +73121,17 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
72742
73121
  */
72743
73122
  buildTimelapsePorts(api, stores) {
72744
73123
  return {
72745
- getAvailability: async ({ deviceId, fromMs, toMs }) => {
72746
- return (await api.recording.getAvailability.query({
73124
+ getAvailability: async ({ deviceId, fromMs, toMs, profile }) => {
73125
+ const availability = await api.recording.getAvailability.query({
72747
73126
  deviceId,
72748
73127
  fromMs,
72749
- toMs
72750
- })).ranges;
73128
+ toMs,
73129
+ ...profile !== void 0 ? { profile } : {}
73130
+ });
73131
+ return {
73132
+ ranges: availability.ranges,
73133
+ profilesWithFootage: availability.profilesWithFootage
73134
+ };
72751
73135
  },
72752
73136
  listTracks: async ({ deviceId, since, until }) => {
72753
73137
  return (await stores.trackStore.queryHistorical({
@@ -77534,6 +77918,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77534
77918
  }
77535
77919
  });
77536
77920
  });
77921
+ p.setBirthZoneTrailObserver((trail) => {
77922
+ this.ctx.logger.info("track born carrying zones it crossed before it was a track", {
77923
+ tags: { deviceId },
77924
+ meta: {
77925
+ source,
77926
+ ...trail
77927
+ }
77928
+ });
77929
+ });
77537
77930
  p.setZoneRefusalObserver((observation) => {
77538
77931
  this.ctx.logger.info("detection refused inside a watched zone — not notified", {
77539
77932
  tags: { deviceId },
@@ -77543,6 +77936,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77543
77936
  }
77544
77937
  });
77545
77938
  });
77939
+ p.setTrackLossObserver((record) => {
77940
+ this.ctx.logger.info("tracker: a track ended and nothing took over", {
77941
+ tags: { deviceId },
77942
+ meta: {
77943
+ source,
77944
+ ...record
77945
+ }
77946
+ });
77947
+ });
77546
77948
  p.setSpawnBesideCoastingObserver((record) => {
77547
77949
  this.ctx.logger.info("tracker: new id minted beside a live coasting track", {
77548
77950
  tags: { deviceId },