@camstack/addon-post-analysis 1.2.270 → 1.2.271

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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-C-nKNO5A.js");
5
+ const require_dist = require("../dist-DTeAk1Ce.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -30055,10 +30055,31 @@ var MediaStore = class {
30055
30055
  * was simply never returned, which is why every retention ops-log row this
30056
30056
  * addon has ever written says `bytesReclaimed: 0` (D161).
30057
30057
  */
30058
- async deleteForOwnerCounted(ownerKind, ownerIds) {
30059
- return this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds));
30058
+ async deleteForOwnerCounted(ownerKind, ownerIds, spare) {
30059
+ return this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds, spare));
30060
30060
  }
30061
- async deleteForOwnerUnchecked(ownerKind, ownerIds) {
30061
+ /**
30062
+ * Who owns a media row — kind and id — or `null` for an unknown id. The
30063
+ * stationary registry asks this before releasing an entry's key frame
30064
+ * (D547): the row is still `track`-owned by a track whose row is gone, and
30065
+ * only when no track row owns it is the entry the last thing vouching for it.
30066
+ */
30067
+ async ownerOf(mediaId) {
30068
+ const row = await this.store.get.query({
30069
+ collection: MEDIA_COLLECTION,
30070
+ key: String(mediaId)
30071
+ });
30072
+ if (!row) return null;
30073
+ const data = row;
30074
+ const ownerKind = String(data["ownerKind"] ?? "");
30075
+ const ownerId = String(data["ownerId"] ?? "");
30076
+ if (!isMediaOwnerKind(ownerKind) || ownerId === "") return null;
30077
+ return {
30078
+ ownerKind,
30079
+ ownerId
30080
+ };
30081
+ }
30082
+ async deleteForOwnerUnchecked(ownerKind, ownerIds, spare) {
30062
30083
  let rows = 0;
30063
30084
  let bytes = 0;
30064
30085
  for (const ownerId of ownerIds) {
@@ -30070,6 +30091,7 @@ var MediaStore = class {
30070
30091
  } }
30071
30092
  });
30072
30093
  for (const row of found) {
30094
+ if (spare !== void 0 && spare.has(Number(row.id))) continue;
30073
30095
  const rowData = row.data;
30074
30096
  const path = String(rowData["path"] ?? "");
30075
30097
  const sizeBytes = Number(rowData["sizeBytes"]);
@@ -30388,16 +30410,17 @@ var MediaStore = class {
30388
30410
  * `deleteForOwner` is best-effort per row, so one bad row can't abort the
30389
30411
  * batch.
30390
30412
  */
30391
- async deleteByTracks(trackIds) {
30413
+ async deleteByTracks(trackIds, opts) {
30414
+ const spare = opts?.spare;
30392
30415
  const own = await this.gated(async () => {
30393
- const tracks = await this.deleteForOwnerUnchecked("track", trackIds);
30394
- const faces = await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
30416
+ const tracks = await this.deleteForOwnerUnchecked("track", trackIds, spare);
30417
+ const faces = await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id), spare);
30395
30418
  return {
30396
30419
  rows: tracks.rows + faces.rows,
30397
30420
  bytes: tracks.bytes + faces.bytes
30398
30421
  };
30399
30422
  });
30400
- const plates = await this.deleteForOwnerCounted("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
30423
+ const plates = await this.deleteForOwnerCounted("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id), spare);
30401
30424
  return [{
30402
30425
  family: "media",
30403
30426
  rows: own.rows + plates.rows,
@@ -39386,7 +39409,9 @@ var FrameProcessor = class {
39386
39409
  const refusals = [];
39387
39410
  const labelPatchByTrack = /* @__PURE__ */ new Map();
39388
39411
  const tracked = trackedDetections.map((td) => {
39389
- const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
39412
+ const objectState = objectStates.find((o) => o.trackId === td.trackId);
39413
+ const state = mapObjectStateToTrackState(objectState?.state);
39414
+ const settling = objectState?.stationarySince !== void 0 && state !== "idle";
39390
39415
  const candidate = resolveDetectionLabelCandidate({
39391
39416
  className: td.class,
39392
39417
  originalClass: td.originalClass,
@@ -39424,6 +39449,7 @@ var FrameProcessor = class {
39424
39449
  zones: zonesByTrack.get(td.trackId) ?? [],
39425
39450
  state,
39426
39451
  matchedThisFrame: td.matchedThisFrame !== false,
39452
+ settling,
39427
39453
  ...td.resurrectionScene !== void 0 ? { resurrectionScene: td.resurrectionScene } : {},
39428
39454
  ...labelPatch !== void 0 ? { labelPatch } : {},
39429
39455
  ...emb !== void 0 ? {
@@ -40419,8 +40445,16 @@ var TrackCloser = class {
40419
40445
  try {
40420
40446
  const trackStore = this.deps.trackStore();
40421
40447
  if (!trackStore) return;
40422
- const expired = await trackStore.expireStale(this.now(), this.deps.isKnownParked !== void 0 ? { isKnownParked: this.deps.isKnownParked } : void 0);
40448
+ const discarded = [];
40449
+ const discardTracks = this.deps.discardTracks;
40450
+ const expired = await trackStore.expireStale(this.now(), {
40451
+ ...this.deps.isKnownParked !== void 0 ? { isKnownParked: this.deps.isKnownParked } : {},
40452
+ ...discardTracks !== void 0 ? { onDiscard: (t) => {
40453
+ discarded.push(t);
40454
+ } } : {}
40455
+ });
40423
40456
  for (const t of expired) await this.closeExpired(t);
40457
+ if (discardTracks !== void 0 && discarded.length > 0) await discardTracks(discarded);
40424
40458
  } catch (err) {
40425
40459
  if (this.deps.isShuttingDown()) return;
40426
40460
  this.deps.logger.debug("sweepExpiredTracks failed", { meta: { error: String(err) } });
@@ -44118,6 +44152,19 @@ var VEHICLE_CLASSES$1 = new Set([
44118
44152
  "truck",
44119
44153
  "bus"
44120
44154
  ]);
44155
+ /**
44156
+ * A short motionless PERSON is stamped and persisted, never discarded (D547).
44157
+ *
44158
+ * The discard exists for re-sightings of an OBJECT an entity represents — a
44159
+ * parked car the registry censuses in two sessions, a plate flash on it. No
44160
+ * entity ever represents a person who stood still for ten seconds (the person
44161
+ * policy needs thirty minutes of agreement, on purpose), and the face read
44162
+ * such a track holds is the only record of who was there. Deleting the track
44163
+ * would delete that; hiding the row from the feed was always the whole rule.
44164
+ */
44165
+ function isDiscardExemptClass(className) {
44166
+ return className.toLowerCase() === "person";
44167
+ }
44121
44168
  function motionlessMaxPxForClass(className) {
44122
44169
  return VEHICLE_CLASSES$1.has(className.toLowerCase()) ? 8 : 40;
44123
44170
  }
@@ -44875,7 +44922,30 @@ var TrackStore = class {
44875
44922
  for (const [trackId, t] of this.active) {
44876
44923
  if (nowMs - t.lastSeen < this.config.ttlMs) continue;
44877
44924
  t.active = false;
44878
- if (t.positions.length > 0 && (opts?.isKnownParked?.(t) === true || isShortMotionlessTrack(t.lastSeen - t.firstSeen, netDisplacementPx(t.positions), t.className))) t.closedReason = "stationary-promotion";
44925
+ const knownParked = t.positions.length > 0 && opts?.isKnownParked?.(t) === true;
44926
+ const shortMotionless = t.positions.length > 0 && isShortMotionlessTrack(t.lastSeen - t.firstSeen, netDisplacementPx(t.positions), t.className);
44927
+ const stillObject = knownParked || shortMotionless;
44928
+ const discardable = knownParked || shortMotionless && !isDiscardExemptClass(t.className);
44929
+ const onDiscard = opts?.onDiscard;
44930
+ if (discardable && onDiscard !== void 0) {
44931
+ const record = cloneTrack(t);
44932
+ this.active.delete(trackId);
44933
+ this.noteRowClosed(trackId, nowMs);
44934
+ this.logger.debug("track discarded on expiry — a still object is an entity, not a track", {
44935
+ tags: { deviceId: t.deviceId },
44936
+ meta: {
44937
+ deviceId: t.deviceId,
44938
+ trackId,
44939
+ className: t.className,
44940
+ lifeMs: t.lastSeen - t.firstSeen,
44941
+ netDisplacementPx: Math.round(netDisplacementPx(t.positions)),
44942
+ knownParked
44943
+ }
44944
+ });
44945
+ onDiscard(record);
44946
+ continue;
44947
+ }
44948
+ if (stillObject) t.closedReason = "stationary-promotion";
44879
44949
  const record = cloneTrack(t);
44880
44950
  try {
44881
44951
  await this.persistCompleted(record);
@@ -53487,6 +53557,7 @@ var FaceRecognizer = class {
53487
53557
  zones: [],
53488
53558
  state: "moving",
53489
53559
  matchedThisFrame: true,
53560
+ settling: false,
53490
53561
  embedding: input.embedding,
53491
53562
  embeddingModelId: modelId,
53492
53563
  ...input.faceBbox !== void 0 ? { faceBbox: input.faceBbox } : {},
@@ -60881,64 +60952,53 @@ function stillnessVerdictFor(source, trackState) {
60881
60952
  return source === "burst" ? void 0 : trackState;
60882
60953
  }
60883
60954
  /**
60884
- * Does this observation count as EVIDENCE that the object is parked?
60885
- *
60886
- * Two questions, deliberately kept apart:
60955
+ * What one observation is worth to a candidate: `agree` (N and M), `disagree`
60956
+ * (M only) or `undecided` (neither).
60887
60957
  *
60888
- * - IDENTITY — is this the same object in the same place? That is the IoU,
60889
- * and it is the only notion of sameness in this module.
60890
- * - STILLNESS — is it going anywhere? On a session frame that is the
60891
- * TRACKER's verdict, and nothing here re-derives it. On a burst frame it is
60892
- * the OCCASION: the runner rechecks a still scene and nothing else (see
60893
- * {@link stillnessVerdictFor}).
60958
+ * ## Why a third answer exists (D547)
60894
60959
  *
60895
- * ## The tracker's verdict is `idle`, and `idle` costs the dwell
60960
+ * The ratio N/M exists to dilute a car that merely drove through the spot.
60961
+ * It was also diluting a car that had not yet had the CHANCE to be called
60962
+ * still: the five `entered` frames every track begins with, and every frame
60963
+ * on which the analyzer's settled test already passed but
60964
+ * `stationaryThresholdSec` had not elapsed — `moving` in name only. On a
60965
+ * session-dispatched camera those frames are a third of every 36 s track, and
60966
+ * a session cut short by a passer-by is made of nothing else. Measured on
60967
+ * device 617 (DNRG309, 2026-09-19 20:32 → 09-20 07:53): 13 of the 21 tracks
60968
+ * bound to the candidate never reached `idle`, the ledger's ratio was 0.493
60969
+ * against 0.6, and a parked car took 11.3 h and 19 tracks to be censused.
60896
60970
  *
60897
- * `idle` used to be reachable in five frames: the analyzer's never-moved
60898
- * shortcut called a track stationary the moment its net travel was ~0 after
60899
- * the entering window, without waiting `stationaryThresholdSec`. That is how
60900
- * the 617 phantom voted — tiny vehicle detections at the top of the frame
60901
- * living 1 to 6 seconds, identical boxes under different ids, which tipped the
60902
- * ledger into an entry on 10 agreements (the real cars carried 773 and 1565).
60903
- * The first answer was a second clock in this module (`MIN_STILL_FOR_EVIDENCE_MS`,
60904
- * 10 s) — a copy of the tracker's own dwell, kept in step by hand. The
60905
- * shortcut is gone instead: `idle` now means the per-device
60906
- * `stationaryThresholdSec` has elapsed, on every camera, and this module has
60907
- * no number of its own.
60908
- *
60909
- * ## A verdict, not the absence of one
60910
- *
60911
- * `entered` — the five-frame warm-up every track begins with — does not agree
60912
- * on a session frame. Measured on 617 over 12 h (2026-09-15): a car merely
60913
- * passing the edge is gone in ~8 frames, five of them `entered`, and 5/8 = 0.62
60914
- * cleared the 0.6 ratio unaided — an entry born on an empty spot, retired 24
60915
- * absent readings later, one "parked" and one "departed" notification for a
60916
- * passage. A parked car's re-detection loses five observations of ~280.
60917
- *
60918
- * A session observation with NO verdict at all does not agree either. There
60919
- * is no trackless producer any more (the hydrated baseline sampler is gone),
60920
- * so an absent verdict on a session frame is a producer that forgot the
60921
- * field — the one way D494 said this could rot — and it must read as a miss,
60922
- * never as the old geometry-only rule.
60971
+ * "Not yet decided" is not evidence either way, so it counts as neither. A
60972
+ * subject that is TRAVELLING — `moving` with the settled test failing — still
60973
+ * dilutes, so the drive-through case D508 measured (5 `entered` of 8 frames)
60974
+ * now scores 0/3 instead of the 5/8 that once minted an entry on an empty spot.
60923
60975
  */
60924
- function observationAgrees(overlap, agreeIou, source, movementState) {
60925
- if (overlap < agreeIou) return false;
60926
- if (source === "burst") return true;
60927
- return movementState === "idle";
60976
+ function observationVote(overlap, agreeIou, source, movementState, settling) {
60977
+ if (overlap < agreeIou) return "disagree";
60978
+ if (source === "burst") return "agree";
60979
+ if (movementState === "idle") return "agree";
60980
+ if (movementState === "entered") return "undecided";
60981
+ if (movementState === "moving" && settling) return "undecided";
60982
+ return "disagree";
60928
60983
  }
60929
60984
  /**
60930
60985
  * Fold one observation into a candidate.
60931
60986
  *
60932
- * `agrees` is decided by the CALLER, because M is what makes the ratio mean
60987
+ * The `vote` is decided by the CALLER, because M is what makes the ratio mean
60933
60988
  * anything: once a track has bound to a candidate, every later observation of
60934
60989
  * that track counts toward M whether it agrees or not. That is what dilutes a
60935
60990
  * car that merely drove through the spot, and it is why this function cannot
60936
60991
  * re-derive agreement from the box alone.
60937
60992
  */
60938
- function foldObservation(row, observation, agrees, overlap) {
60939
- const seen = row.seen + 1;
60993
+ function foldObservation(row, observation, vote, overlap) {
60940
60994
  const rotated = rotateWindow(row, observation.timestamp);
60941
- if (!agrees) return {
60995
+ if (vote === "undecided") return {
60996
+ ...rotated,
60997
+ undecided: row.undecided + 1,
60998
+ updatedAt: observation.timestamp
60999
+ };
61000
+ const seen = row.seen + 1;
61001
+ if (vote === "disagree") return {
60942
61002
  ...rotated,
60943
61003
  seen,
60944
61004
  windowSeen: rotated.windowSeen + 1,
@@ -60986,6 +61046,7 @@ function newEvidenceRow(observation, anchorId) {
60986
61046
  ...observation.label !== void 0 ? { label: observation.label } : {},
60987
61047
  sessionAgreed: observation.source === "session" ? 1 : 0,
60988
61048
  burstAgreed: observation.source === "burst" ? 1 : 0,
61049
+ undecided: 0,
60989
61050
  updatedAt: observation.timestamp
60990
61051
  };
60991
61052
  }
@@ -61058,6 +61119,10 @@ var STATIONARY_EVIDENCE_COLUMNS = [
61058
61119
  name: "burstAgreed",
61059
61120
  type: "INTEGER"
61060
61121
  },
61122
+ {
61123
+ name: "undecided",
61124
+ type: "INTEGER"
61125
+ },
61061
61126
  {
61062
61127
  name: "updatedAt",
61063
61128
  type: "INTEGER"
@@ -61105,6 +61170,9 @@ var StationaryEvidenceLedger = class {
61105
61170
  * every observation of a box that never changes (3836's jacket is seen five
61106
61171
  * times a second in a session), so the line is said once per candidate. */
61107
61172
  implausibleReported = /* @__PURE__ */ new Set();
61173
+ /** `<deviceId>:<trackId>` of tracks whose first undecided fold was logged
61174
+ * (D547). Pruned with the bindings in {@link retainTracks}. */
61175
+ undecidedReported = /* @__PURE__ */ new Set();
61108
61176
  anchorSeq = 0;
61109
61177
  constructor(deps) {
61110
61178
  this.logger = deps.logger;
@@ -61133,6 +61201,7 @@ var StationaryEvidenceLedger = class {
61133
61201
  ...row.label !== void 0 ? { label: row.label } : {},
61134
61202
  sessionAgreed: row.sessionAgreed,
61135
61203
  burstAgreed: row.burstAgreed,
61204
+ undecided: row.undecided,
61136
61205
  updatedAt: row.updatedAt
61137
61206
  }),
61138
61207
  fromRecord: (key, data) => {
@@ -61166,6 +61235,7 @@ var StationaryEvidenceLedger = class {
61166
61235
  ...label !== void 0 ? { label } : {},
61167
61236
  sessionAgreed: numberOr(data.sessionAgreed, 0),
61168
61237
  burstAgreed: numberOr(data.burstAgreed, 0),
61238
+ undecided: numberOr(data.undecided, 0),
61169
61239
  updatedAt
61170
61240
  };
61171
61241
  },
@@ -61300,8 +61370,10 @@ var StationaryEvidenceLedger = class {
61300
61370
  }
61301
61371
  if (bindKey !== void 0) this.binding.set(bindKey, row.key);
61302
61372
  }
61303
- const agrees = observationAgrees(overlap, policy.agreeIou, observation.source, observation.movementState);
61304
- const next = foldObservation(row, observation, agrees, overlap);
61373
+ const vote = observationVote(overlap, policy.agreeIou, observation.source, observation.movementState, observation.settling === true);
61374
+ const agrees = vote === "agree";
61375
+ if (vote === "undecided") this.noteUndecided(row, observation);
61376
+ const next = foldObservation(row, observation, vote, overlap);
61305
61377
  this.ledger.stage(next);
61306
61378
  this.dirty.add(next.key);
61307
61379
  if (created) this.capDevice(observation.deviceId);
@@ -61351,6 +61423,10 @@ var StationaryEvidenceLedger = class {
61351
61423
  if (!bindKey.startsWith(prefix)) continue;
61352
61424
  if (!liveTrackIds.has(bindKey.slice(prefix.length))) this.binding.delete(bindKey);
61353
61425
  }
61426
+ for (const key of [...this.undecidedReported]) {
61427
+ if (!key.startsWith(prefix)) continue;
61428
+ if (!liveTrackIds.has(key.slice(prefix.length))) this.undecidedReported.delete(key);
61429
+ }
61354
61430
  }
61355
61431
  forgetKey(key) {
61356
61432
  this.forgetKeyDurable(key);
@@ -61363,6 +61439,32 @@ var StationaryEvidenceLedger = class {
61363
61439
  * work is never silent (D391), and one that refuses it five times a second
61364
61440
  * is never loud.
61365
61441
  */
61442
+ /**
61443
+ * An undecided observation is dropped from the tally on purpose (D547), and
61444
+ * a branch that drops work says so — once per track, at debug: the same
61445
+ * track offers one every frame until it decides, and a line per frame would
61446
+ * bury what it reports. The lifetime count rides the row (`undecided`).
61447
+ */
61448
+ noteUndecided(row, observation) {
61449
+ const trackId = observation.trackId;
61450
+ if (trackId === void 0) return;
61451
+ const key = `${observation.deviceId}:${trackId}`;
61452
+ if (this.undecidedReported.has(key)) return;
61453
+ this.undecidedReported.add(key);
61454
+ this.logger.debug("stationary evidence: undecided observation — counted as neither N nor M", {
61455
+ tags: { deviceId: observation.deviceId },
61456
+ meta: {
61457
+ deviceId: observation.deviceId,
61458
+ trackId,
61459
+ candidateKey: row.key,
61460
+ movementState: observation.movementState ?? null,
61461
+ settling: observation.settling === true,
61462
+ agreed: row.agreed,
61463
+ seen: row.seen,
61464
+ undecided: row.undecided
61465
+ }
61466
+ });
61467
+ }
61366
61468
  noteImplausible(row, frame) {
61367
61469
  if (this.implausibleReported.has(row.key)) return;
61368
61470
  this.implausibleReported.add(row.key);
@@ -61435,6 +61537,65 @@ var StationaryEvidenceLedger = class {
61435
61537
  for (const row of excess) this.forgetKey(row.key);
61436
61538
  }
61437
61539
  };
61540
+ //#endregion
61541
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-track-disposal.ts
61542
+ function planStationaryTrackDisposal(input) {
61543
+ const trackIds = [input.liveTrackId];
61544
+ if (input.sourceTrackId !== void 0 && input.sourceTrackId !== input.liveTrackId) trackIds.push(input.sourceTrackId);
61545
+ const keepMediaIds = /* @__PURE__ */ new Set();
61546
+ if (input.entryKeyFrameMediaId !== void 0) keepMediaIds.add(input.entryKeyFrameMediaId);
61547
+ return {
61548
+ trackIds,
61549
+ keepMediaIds
61550
+ };
61551
+ }
61552
+ /**
61553
+ * Run the plan. The live track is dropped from RAM FIRST so it can never be
61554
+ * persisted by a sweep racing this call; the cascade then removes what every
61555
+ * track owned, sparing the entry's key frame, and deletes the row (a no-op for
61556
+ * the never-persisted live track).
61557
+ *
61558
+ * Never silent: the line names the entity, what went and what was kept — a
61559
+ * deleted track is dropped work, and the operator asked for it by name.
61560
+ */
61561
+ async function disposeStationaryTracks(deps, plan) {
61562
+ const [liveTrackId] = plan.trackIds;
61563
+ if (liveTrackId !== void 0) deps.trackStore.dropActive(liveTrackId);
61564
+ const sparingMedia = { deleteByTracks: (trackIds) => deps.media.deleteByTracks(trackIds, { spare: plan.keepMediaIds }) };
61565
+ const result = await runTrackCascadeBatch({
61566
+ registry: [...deps.leaves, sparingMedia],
61567
+ trackStore: deps.trackStore,
61568
+ deviceId: deps.deviceId,
61569
+ onTrackCleanup: deps.onTrackCleanup,
61570
+ onFailure: (trackId, err) => {
61571
+ deps.logger.warn("stationary promotion: a source track could not be disposed of", {
61572
+ tags: { deviceId: deps.deviceId },
61573
+ meta: {
61574
+ deviceId: deps.deviceId,
61575
+ entryId: deps.entryId,
61576
+ trackId,
61577
+ error: String(err)
61578
+ }
61579
+ });
61580
+ }
61581
+ }, plan.trackIds);
61582
+ deps.logger.info("stationary promotion: source tracks discarded — the entity keeps its key frame", {
61583
+ tags: { deviceId: deps.deviceId },
61584
+ meta: {
61585
+ deviceId: deps.deviceId,
61586
+ entryId: deps.entryId,
61587
+ trackIds: plan.trackIds,
61588
+ keptMediaIds: [...plan.keepMediaIds],
61589
+ deleted: result.deleted,
61590
+ failed: result.failed,
61591
+ mediaRows: result.tally.media
61592
+ }
61593
+ });
61594
+ return {
61595
+ deleted: result.deleted,
61596
+ failed: result.failed
61597
+ };
61598
+ }
61438
61599
  /**
61439
61600
  * How long the same weak box must keep being seen before it may re-anchor.
61440
61601
  *
@@ -61614,6 +61775,7 @@ var StationaryObjectRegistry = class {
61614
61775
  sessionGapMs;
61615
61776
  warmUpMs;
61616
61777
  ttlForDevice;
61778
+ onEntryDropped;
61617
61779
  onChange;
61618
61780
  requestConfirmationLook;
61619
61781
  /**
@@ -61707,6 +61869,7 @@ var StationaryObjectRegistry = class {
61707
61869
  this.sessionGapMs = deps.sessionGapMs ?? 5e3;
61708
61870
  this.warmUpMs = deps.warmUpMs ?? 2e3;
61709
61871
  this.ttlForDevice = deps.ttlForDevice;
61872
+ this.onEntryDropped = deps.onEntryDropped;
61710
61873
  this.onChange = deps.onChange;
61711
61874
  this.requestConfirmationLook = deps.requestConfirmationLook;
61712
61875
  }
@@ -62316,6 +62479,7 @@ var StationaryObjectRegistry = class {
62316
62479
  return at !== void 0 && at > entry.lastConfirmedAt;
62317
62480
  }
62318
62481
  dropEntry(deviceId, id) {
62482
+ const dropped = this.ledger.get(id);
62319
62483
  this.idsByDevice.get(deviceId)?.delete(id);
62320
62484
  this.dirty.delete(id);
62321
62485
  this.weakRuns.delete(id);
@@ -62325,6 +62489,7 @@ var StationaryObjectRegistry = class {
62325
62489
  if (armed.size === 0) this.departureStrikes.delete(deviceId);
62326
62490
  }
62327
62491
  this.ledger.forget(id);
62492
+ if (dropped !== void 0) this.onEntryDropped?.(dropped);
62328
62493
  }
62329
62494
  };
62330
62495
  /** The persisted column map for an entry (the `id` PK is passed separately). */
@@ -68257,8 +68422,10 @@ async function auditScope(scope, deps, limits, cursor) {
68257
68422
  let liveTracks;
68258
68423
  const liveEvents = /* @__PURE__ */ new Map();
68259
68424
  let liveSummaries;
68425
+ let keptMedia = /* @__PURE__ */ new Set();
68260
68426
  try {
68261
68427
  liveTracks = trackIds.length > 0 ? await deps.liveTrackIds(trackIds) : /* @__PURE__ */ new Set();
68428
+ if (deps.keptMediaKeys !== void 0 && scope.collection === "pipeline-analytics:media") keptMedia = await deps.keptMediaKeys(rows.map((row) => row.key));
68262
68429
  for (const [table, ids] of eventIdsByTable) {
68263
68430
  if (ids.length === 0) continue;
68264
68431
  liveEvents.set(table, await deps.liveEventIds(table, ids));
@@ -68291,7 +68458,7 @@ async function auditScope(scope, deps, limits, cursor) {
68291
68458
  tally.unresolvable += 1;
68292
68459
  continue;
68293
68460
  }
68294
- if (ref.kind === "track" ? liveTracks.has(ref.trackId) : ref.kind === "event" ? liveEvents.get(ref.table)?.has(ref.eventId) ?? false : ref.kind === "summary" ? liveSummaries.has(ref.summaryId) : false) continue;
68461
+ if (ref.kind === "track" ? liveTracks.has(ref.trackId) || keptMedia.has(row.key) : ref.kind === "event" ? liveEvents.get(ref.table)?.has(ref.eventId) ?? false : ref.kind === "summary" ? liveSummaries.has(ref.summaryId) : false) continue;
68295
68462
  const ts = num(row.data, scope.timestampField);
68296
68463
  if (ts !== null && ts > graceFloor) {
68297
68464
  tally.withinGrace += 1;
@@ -78025,10 +78192,149 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
78025
78192
  const last = track.positions.at(-1)?.bbox;
78026
78193
  if (!last) return false;
78027
78194
  return bboxMatchesParkedEntry(last, track.className, this.stationaryRegistry?.listViews(track.deviceId) ?? []);
78028
- }
78195
+ },
78196
+ discardTracks: (tracks) => this.cascadeDiscardedTracks(tracks)
78029
78197
  });
78030
78198
  return this._trackCloser;
78031
78199
  }
78200
+ /**
78201
+ * Cascade what `expireStale` refused to persist (D547): scenery re-fires and
78202
+ * re-sightings of a censused object. Per device, per track, through the
78203
+ * shipped registry — never a raw delete — so the vector, the crops and the
78204
+ * events go with the media. A track whose cascade fails is named.
78205
+ */
78206
+ async cascadeDiscardedTracks(tracks) {
78207
+ const cascade = this.buildTrackCascadeRegistry();
78208
+ if (!cascade) {
78209
+ this.ctx.logger.warn("discarded tracks could not be cascaded — stores not ready", { meta: { trackIds: tracks.map((t) => t.trackId) } });
78210
+ return;
78211
+ }
78212
+ const byDevice = /* @__PURE__ */ new Map();
78213
+ for (const t of tracks) {
78214
+ const ids = byDevice.get(t.deviceId) ?? [];
78215
+ ids.push(t.trackId);
78216
+ byDevice.set(t.deviceId, ids);
78217
+ }
78218
+ for (const [deviceId, trackIds] of byDevice) {
78219
+ const result = await runTrackCascadeBatch({
78220
+ registry: cascade.registry,
78221
+ trackStore: cascade.trackStore,
78222
+ deviceId,
78223
+ onTrackCleanup: (id, trackId) => this.clearLiveTrackState(id, trackId),
78224
+ onFailure: (trackId, err) => {
78225
+ this.ctx.logger.warn("discarded track could not be cascaded", {
78226
+ tags: { deviceId },
78227
+ meta: {
78228
+ deviceId,
78229
+ trackId,
78230
+ error: String(err)
78231
+ }
78232
+ });
78233
+ }
78234
+ }, trackIds);
78235
+ this.ctx.logger.info("tracks discarded on expiry — still objects are entities, not tracks", {
78236
+ tags: { deviceId },
78237
+ meta: {
78238
+ deviceId,
78239
+ trackIds,
78240
+ deleted: result.deleted,
78241
+ failed: result.failed,
78242
+ mediaRows: result.tally.media
78243
+ }
78244
+ });
78245
+ }
78246
+ }
78247
+ /**
78248
+ * Dispose of the tracks that carried a promoted candidate's evidence (D547):
78249
+ * the one alive on the tipping frame and the persisted source of the entry's
78250
+ * best box. The entry's key frame is spared — it is the one row the entity
78251
+ * vouches for, and `keptStationaryMediaKeys` keeps the orphan audit off it.
78252
+ */
78253
+ async disposePromotedTracks(deviceId, entry, liveTrackId, sourceTrackId) {
78254
+ const cascade = this.buildTrackCascadeRegistry();
78255
+ const mediaStore = this.mediaStore;
78256
+ if (!cascade || !mediaStore) {
78257
+ this.ctx.logger.warn("stationary promotion: source tracks could not be disposed of — stores not ready", {
78258
+ tags: { deviceId },
78259
+ meta: {
78260
+ deviceId,
78261
+ entryId: entry.id,
78262
+ liveTrackId,
78263
+ sourceTrackId: sourceTrackId ?? null
78264
+ }
78265
+ });
78266
+ return;
78267
+ }
78268
+ const plan = planStationaryTrackDisposal({
78269
+ liveTrackId,
78270
+ ...sourceTrackId !== void 0 ? { sourceTrackId } : {},
78271
+ ...entry.keyFrameMediaId !== void 0 ? { entryKeyFrameMediaId: entry.keyFrameMediaId } : {}
78272
+ });
78273
+ await disposeStationaryTracks({
78274
+ deviceId,
78275
+ entryId: entry.id,
78276
+ leaves: cascade.registry.filter((leaf) => leaf !== mediaStore),
78277
+ media: mediaStore,
78278
+ trackStore: cascade.trackStore,
78279
+ onTrackCleanup: (id, trackId) => this.clearLiveTrackState(id, trackId),
78280
+ logger: this.ctx.logger
78281
+ }, plan);
78282
+ }
78283
+ /**
78284
+ * Media row keys a live stationary entry vouches for (D547) — the orphan
78285
+ * audit's probe. Answered from the registry's RAM index, which is the
78286
+ * authority on which entries are alive.
78287
+ */
78288
+ keptStationaryMediaKeys(keys) {
78289
+ const registry = this.stationaryRegistry;
78290
+ if (!registry) return /* @__PURE__ */ new Set();
78291
+ const held = /* @__PURE__ */ new Set();
78292
+ for (const deviceId of registry.deviceIds()) for (const entry of registry.list(deviceId)) if (entry.keyFrameMediaId !== void 0) held.add(String(entry.keyFrameMediaId));
78293
+ return new Set(keys.filter((key) => held.has(key)));
78294
+ }
78295
+ /**
78296
+ * An entry left the registry: free the key frame it vouched for (D547),
78297
+ * unless another live entry still holds it or a track row still owns it
78298
+ * (then retention's cascade will). Never silent either way.
78299
+ */
78300
+ async releaseStationaryKeyFrame(entry) {
78301
+ const mediaId = entry.keyFrameMediaId;
78302
+ if (mediaId === void 0) return;
78303
+ const deviceId = entry.deviceId;
78304
+ const registry = this.stationaryRegistry;
78305
+ const mediaStore = this.mediaStore;
78306
+ const trackStore = this.trackStore;
78307
+ if (!registry || !mediaStore || !trackStore) return;
78308
+ if (registry.list(deviceId).some((e) => e.keyFrameMediaId === mediaId)) return;
78309
+ try {
78310
+ const owner = await mediaStore.ownerOf(mediaId);
78311
+ if (owner === null) return;
78312
+ if (owner.ownerKind === "track") {
78313
+ if ((await trackStore.existingTrackIds([owner.ownerId])).has(owner.ownerId)) return;
78314
+ }
78315
+ await mediaStore.deleteById(mediaId);
78316
+ this.ctx.logger.info("stationary entry released its key frame", {
78317
+ tags: { deviceId },
78318
+ meta: {
78319
+ deviceId,
78320
+ entryId: entry.id,
78321
+ mediaId,
78322
+ ownerKind: owner.ownerKind,
78323
+ ownerId: owner.ownerId
78324
+ }
78325
+ });
78326
+ } catch (err) {
78327
+ this.ctx.logger.warn("stationary entry key frame could not be released", {
78328
+ tags: { deviceId },
78329
+ meta: {
78330
+ deviceId,
78331
+ entryId: entry.id,
78332
+ mediaId,
78333
+ error: String(err)
78334
+ }
78335
+ });
78336
+ }
78337
+ }
78032
78338
  /** Master toggle for the migrated object/face EmbeddingDispatcher. Read at
78033
78339
  * init; a change takes effect on addon restart (same convention as
78034
78340
  * `postProcessingNodeId`).
@@ -78754,6 +79060,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
78754
79060
  store: api.settingsStore,
78755
79061
  logger: logger.child("StationaryRegistry"),
78756
79062
  ttlForDevice: (deviceId) => this.stationarySettingsFromCache(deviceId).entryTtlMs,
79063
+ onEntryDropped: (entry) => {
79064
+ this.releaseStationaryKeyFrame(entry);
79065
+ },
78757
79066
  requestConfirmationLook: (ask) => this.requestConfirmationLook(ask),
78758
79067
  onChange: ({ phase, entry, timestamp, observedAt }) => {
78759
79068
  this.ctx.eventBus.emit({
@@ -80142,6 +80451,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
80142
80451
  anchor: { ...row.anchor },
80143
80452
  agreed: row.agreed,
80144
80453
  seen: row.seen,
80454
+ undecided: row.undecided,
80145
80455
  ratio: row.seen > 0 ? row.agreed / row.seen : 0,
80146
80456
  spanMs: row.lastAgreedAt - row.firstAgreedAt,
80147
80457
  firstAgreedAt: row.firstAgreedAt,
@@ -81542,7 +81852,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
81542
81852
  frameHeight: result.frameHeight,
81543
81853
  ...observedKeyFrame !== void 0 ? { keyFrameMediaId: observedKeyFrame } : {},
81544
81854
  ...observedLabel !== void 0 ? { label: observedLabel } : {},
81545
- ...movementState !== void 0 ? { movementState } : {}
81855
+ ...movementState !== void 0 ? { movementState } : {},
81856
+ settling: t.settling
81546
81857
  };
81547
81858
  const outcome = evidence.observe(observation);
81548
81859
  if (outcome === void 0 || !outcome.promoted) continue;
@@ -84692,17 +85003,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
84692
85003
  wakeAssocIou: gate.wakeAssocIou
84693
85004
  });
84694
85005
  processor.dropTrack(track.trackId);
84695
- this.trackStore?.closeActive(track.trackId, "stationary-promotion").catch((err) => {
84696
- this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
84697
- tags: { deviceId },
84698
- meta: {
84699
- deviceId,
84700
- trackId: track.trackId,
84701
- entryId: entry.id,
84702
- error: String(err)
84703
- }
84704
- });
84705
- });
84706
85006
  const faceEnd = this.faceRecognizer?.onTrackEnd(deviceId, track.trackId);
84707
85007
  const dropKeyFrame = () => {
84708
85008
  this.residents.dropKeyFrameMediaId(track.trackId);
@@ -84710,7 +85010,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
84710
85010
  if (faceEnd) faceEnd.finally(dropKeyFrame);
84711
85011
  else dropKeyFrame();
84712
85012
  const promotedPath = this.trackStore?.getActiveByTrack(track.trackId)?.positions;
84713
- this.plateRecognizer?.onTrackEnd(deviceId, track.trackId, promotedPath);
85013
+ const plateEnd = this.plateRecognizer?.onTrackEnd(deviceId, track.trackId, promotedPath);
85014
+ Promise.allSettled([faceEnd, plateEnd]).then(() => this.disposePromotedTracks(deviceId, entry, track.trackId, row.sourceTrackId));
84714
85015
  this.residents.close(track.trackId, "stationary");
84715
85016
  this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
84716
85017
  this.overlayState.onTrackEnded(deviceId, track.trackId);
@@ -85653,6 +85954,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
85653
85954
  if (!summaries) throw new Error("summary store not built — probe unavailable");
85654
85955
  return summaries.existingIds(ids);
85655
85956
  },
85957
+ keptMediaKeys: async (keys) => this.keptStationaryMediaKeys(keys),
85656
85958
  reclaim: buildOrphanReclaimer({
85657
85959
  deleteFace: async (faceId) => {
85658
85960
  await this.faceStore?.delete(faceId);