@camstack/addon-post-analysis 1.2.127 → 1.2.129

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 customAction, A as NcTaxonomySchema, At as sleep, B as TIMELAPSE_DENSE_FLOOR_SEC, Bt as string, C as NcRuleSchema, Ct as errMsg, D as NcSnoozeSchema, Dt as hydrateSchema, E as NcSnoozeInputSchema, Et as createEvent, Ft as literal, G as VISIT_MERGE_GAP_MS, H as TimelapseRulePatchSchema, Ht as EventCategory, I as SCENE_DEFAULT_ANCHOR_THRESHOLD, It as number, J as assertTimelapseCadences, K as addonWidgetsSourceCapability, L as SCENE_DEFAULT_UNCOVERED_POLICY, Lt as object, Mt as array, N as RECORDING_EXPORT_MAX_READ_BYTES, Nt as boolean, O as NcSnoozeSuppressedSchema, Ot as isDeviceScopedCap, P as RetrainStatusSchema, Pt as discriminatedUnion, Q as cosineSimilarity$1, R as SCENE_DIVERGED, Rt as partialRecord, S as NcRulePatchSchema, St as zoneAnalyticsCapability, T as NcScheduleSchema, Tt as DeviceType, U as TimelapseRuleSchema, V as TimelapseRuleInputSchema, Vt as unknown, W as TrackSourceSchema, X as audioModeOf, Y as audioMetricsCapability, Z as buildEventKindDescriptor, _ as NC_DEFAULT_SNOOZE_MINUTES, _t as sceneMonitorCapability, a as DEFAULT_EVENT_COLOR, b as NcConditionDescriptorSchema, bt as vectorDimFromBase64, ct as kebabToCamel, d as EVENT_PAD_MS, dt as pickClusterStepModels, et as defineCustomActions, f as FULL_IMAGE_BBOX, ft as pipelineAnalyticsCapability, g as NC_CONDITION_CATALOG, h as NC_ALARM_SYSTEM_EVENT_KINDS, ht as readTimelapseGeneratedAt, i as CLUSTER_MODEL_SCOPED_STEPS, it as faceGalleryCapability, j as OpsLogEntrySchema, jt as _enum, k as NcSystemEventKindSchema, kt as nodePin, l as DeclaredDevices, lt as notificationRulesCapability, m as MACRO_LABELS, mt as readDeviceStateFrom, n as AnalyticsGroupRecordSchema, ot as isDetectionMacroClass, p as LabelAttributionSchema, pt as plateGalleryCapability, q as alarmPanelCapability, r as BaseDevice, rt as encodeVectorBase64, s as DETECTION_MACRO_CLASSES, st as isScheduleActive, t as AnalyticsGroupDetailSchema, tt as deriveRecordingMode, u as EVENT_KIND_BY_CAP, vt as subKindsOf, w as NcRuleTargetSchema, wt as BaseAddon, x as NcRuleInputSchema, xt as videoclipsCapability, y as NC_TAXONOMY, yt as systemEventFilterApplies, z as SceneMonitorSchema, zt as record } from "../dist-BDy0xpfI.mjs";
1
+ import { $ as customAction, A as NcTaxonomySchema, At as sleep, B as TIMELAPSE_DENSE_FLOOR_SEC, Bt as string, C as NcRuleSchema, Ct as errMsg, D as NcSnoozeSchema, Dt as hydrateSchema, E as NcSnoozeInputSchema, Et as createEvent, Ft as literal, G as VISIT_MERGE_GAP_MS, H as TimelapseRulePatchSchema, Ht as EventCategory, I as SCENE_DEFAULT_ANCHOR_THRESHOLD, It as number, J as assertTimelapseCadences, K as addonWidgetsSourceCapability, L as SCENE_DEFAULT_UNCOVERED_POLICY, Lt as object, Mt as array, N as RECORDING_EXPORT_MAX_READ_BYTES, Nt as boolean, O as NcSnoozeSuppressedSchema, Ot as isDeviceScopedCap, P as RetrainStatusSchema, Pt as discriminatedUnion, Q as cosineSimilarity$1, R as SCENE_DIVERGED, Rt as partialRecord, S as NcRulePatchSchema, St as zoneAnalyticsCapability, T as NcScheduleSchema, Tt as DeviceType, U as TimelapseRuleSchema, V as TimelapseRuleInputSchema, Vt as unknown, W as TrackSourceSchema, X as audioModeOf, Y as audioMetricsCapability, Z as buildEventKindDescriptor, _ as NC_DEFAULT_SNOOZE_MINUTES, _t as sceneMonitorCapability, a as DEFAULT_EVENT_COLOR, b as NcConditionDescriptorSchema, bt as vectorDimFromBase64, ct as kebabToCamel, d as EVENT_PAD_MS, dt as pickClusterStepModels, et as defineCustomActions, f as FULL_IMAGE_BBOX, ft as pipelineAnalyticsCapability, g as NC_CONDITION_CATALOG, h as NC_ALARM_SYSTEM_EVENT_KINDS, ht as readTimelapseGeneratedAt, i as CLUSTER_MODEL_SCOPED_STEPS, it as faceGalleryCapability, j as OpsLogEntrySchema, jt as _enum, k as NcSystemEventKindSchema, kt as nodePin, l as DeclaredDevices, lt as notificationRulesCapability, m as MACRO_LABELS, mt as readDeviceStateFrom, n as AnalyticsGroupRecordSchema, ot as isDetectionMacroClass, p as LabelAttributionSchema, pt as plateGalleryCapability, q as alarmPanelCapability, r as BaseDevice, rt as encodeVectorBase64, s as DETECTION_MACRO_CLASSES, st as isScheduleActive, t as AnalyticsGroupDetailSchema, tt as deriveRecordingMode, u as EVENT_KIND_BY_CAP, vt as subKindsOf, w as NcRuleTargetSchema, wt as BaseAddon, x as NcRuleInputSchema, xt as videoclipsCapability, y as NC_TAXONOMY, yt as systemEventFilterApplies, z as SceneMonitorSchema, zt as record } from "../dist-CGhP6T_c.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";
@@ -27195,6 +27195,18 @@ var FaceGalleryProvider = class {
27195
27195
  * This module names that state (`model-mismatch`) so the UI can say "this face
27196
27196
  * is in an old feature space" instead of showing 0% and letting the operator
27197
27197
  * conclude the person is not there.
27198
+ *
27199
+ * ## `buildFaceRescoreReport` stays pure — the CALLER persists
27200
+ *
27201
+ * This function never touches the store; every input is passed in and nothing
27202
+ * is read or written (see its own doc below). The operator asked for this
27203
+ * exact recompute, though, and wants the answer to stick — so the ACTION that
27204
+ * wraps this report (`rescoreFaceForTrack` in `pipeline-analytics/index.ts`)
27205
+ * writes the result onto `Face.suggestedIdentityId` / `suggestedMatchScore`,
27206
+ * through `decideRescorePersistence` / `persistRescoreResult` below. That is
27207
+ * still not a background job — it is the SAME explicitly-requested call,
27208
+ * writing once. A `model-mismatch` report (`comparable: false`) is never
27209
+ * written: see `persistRescoreResult`.
27198
27210
  */
27199
27211
  /** The empty report every "cannot score" path returns, so shape never varies. */
27200
27212
  function emptyReport(verdict, input, comparable) {
@@ -27270,6 +27282,52 @@ function buildFaceRescoreReport(input) {
27270
27282
  ...best !== void 0 && second !== void 0 ? { runnerUpGap: best.score - second.score } : {}
27271
27283
  };
27272
27284
  }
27285
+ /**
27286
+ * What `face.rescoreTrack` should PERSIST for a COMPARABLE report, decided as
27287
+ * its own pure function so the rule is directly testable — separated from the
27288
+ * class that owns the store I/O (`rescoreFaceForTrack` in
27289
+ * `pipeline-analytics/index.ts`), which only wires this answer to
27290
+ * `FaceStore.setSuggestion`.
27291
+ *
27292
+ * `null` covers every verdict OTHER than `auto-assignable` / `suggestion`, and
27293
+ * also an `auto-assignable` / `suggestion` verdict whose best-ranked row is
27294
+ * not itself ELIGIBLE (`identities` is ranked by raw score across every
27295
+ * comparable identity, eligible or not — see `buildFaceRescoreReport`). Every
27296
+ * one of those is a genuine "nothing to suggest right now": never an
27297
+ * assignment, and never the winner of an `ambiguous` verdict — the matcher
27298
+ * refused to pick one, so this function does not pick one either.
27299
+ *
27300
+ * Callers MUST gate on `report.comparable` before calling this — a
27301
+ * `model-mismatch` report has no verdict worth acting on; see
27302
+ * {@link persistRescoreResult}, which does that gating and is the one thing
27303
+ * `rescoreFaceForTrack` actually calls.
27304
+ */
27305
+ function decideRescorePersistence(report) {
27306
+ if (report.verdict !== "auto-assignable" && report.verdict !== "suggestion") return null;
27307
+ const candidate = report.identities.find((row) => row.eligible);
27308
+ return candidate === void 0 ? null : {
27309
+ identityId: candidate.identityId,
27310
+ score: candidate.score
27311
+ };
27312
+ }
27313
+ /**
27314
+ * Persist `face.rescoreTrack`'s result onto the SUGGESTED tier — the ONE
27315
+ * write this explicitly-requested action makes, and the only place that
27316
+ * decides whether it happens at all.
27317
+ *
27318
+ * A non-comparable report (`model-mismatch`) short-circuits BEFORE
27319
+ * `ports.setSuggestion` is ever called: a cosine across model spaces is
27320
+ * well-formed noise, and persisting it would look exactly like a fresh,
27321
+ * meaningful read to every future reader of the row. This is the single most
27322
+ * load-bearing branch in this file — the regression it guards against writes
27323
+ * garbage that reads as data.
27324
+ */
27325
+ async function persistRescoreResult(faceId, report, ports) {
27326
+ if (!report.comparable) return "not-comparable";
27327
+ const candidate = decideRescorePersistence(report);
27328
+ await ports.setSuggestion(faceId, candidate);
27329
+ return candidate !== null ? "written" : "cleared";
27330
+ }
27273
27331
  //#endregion
27274
27332
  //#region src/pipeline-analytics/face-rescore-actions.ts
27275
27333
  /**
@@ -27355,9 +27413,12 @@ var FaceReembedReportSchema = object({
27355
27413
  });
27356
27414
  var faceRescoreActions = defineCustomActions({
27357
27415
  /**
27358
- * Read-only by default. `admin` because the report names every enrolled
27359
- * person and how well an arbitrary face matches them — the gallery's shape,
27360
- * which is not a viewer-level fact.
27416
+ * Persists its own result onto the SUGGESTED tier (`suggestedIdentityId` /
27417
+ * `suggestedMatchScore`) — never an assignment, never automatic; see
27418
+ * `rescoreFaceForTrack` in `pipeline-analytics/index.ts` and
27419
+ * `persistRescoreResult` in `face-rescore.ts`. `admin` because the report
27420
+ * also names every enrolled person and how well an arbitrary face matches
27421
+ * them — the gallery's shape, which is not a viewer-level fact.
27361
27422
  */
27362
27423
  "face.rescoreTrack": customAction(object({
27363
27424
  deviceId: number().int(),
@@ -27375,8 +27436,10 @@ var faceRescoreActions = defineCustomActions({
27375
27436
  /** The face's current assignment, so the UI can mark "already this person". */
27376
27437
  assigned: boolean(),
27377
27438
  assignedIdentityId: string().optional(),
27378
- /** What the track has been SHOWING until now — persisted at detection time.
27379
- * Returned so the UI can put the stale number beside the fresh one. */
27439
+ /** What the track has been SHOWING until now — read BEFORE this call
27440
+ * writes, so it is whatever a previous track close or a previous
27441
+ * `face.rescoreTrack` last persisted. Returned so the UI can put the stale
27442
+ * number beside the fresh one. */
27380
27443
  storedSuggestedIdentityId: string().optional(),
27381
27444
  storedSuggestedMatchScore: number().optional(),
27382
27445
  verdict: FaceRescoreVerdictSchema,
@@ -29383,6 +29446,51 @@ var FrameProcessor = class {
29383
29446
  }
29384
29447
  };
29385
29448
  //#endregion
29449
+ //#region src/pipeline-analytics/replay/frame-processor-pass.ts
29450
+ /**
29451
+ * Run a FRESH `FrameProcessor` over a fixed, ordered sequence of frames and
29452
+ * return the tracks it produced.
29453
+ *
29454
+ * No `TrackStore`, no bus, no media — a track here is a summary built
29455
+ * ENTIRELY from `FrameProcessor.process()`'s in-memory return value.
29456
+ */
29457
+ function runFrameProcessorPass(input) {
29458
+ const processor = new FrameProcessor(input.deviceId, {}, {}, {}, input.source);
29459
+ if (input.zones !== void 0) processor.setZones(input.zones);
29460
+ if (input.detectionRules !== void 0) processor.setDetectionRules(input.detectionRules);
29461
+ if (input.zoneMembershipMinOverlap !== void 0) processor.setZoneMembershipMinOverlap(input.zoneMembershipMinOverlap);
29462
+ const accum = /* @__PURE__ */ new Map();
29463
+ for (const { timestamp, frame } of input.frames) {
29464
+ const result = processor.process({
29465
+ timestamp,
29466
+ frame
29467
+ });
29468
+ for (const td of result.tracked) {
29469
+ const existing = accum.get(td.trackId);
29470
+ const matched = td.matchedThisFrame ? 1 : 0;
29471
+ if (existing === void 0) accum.set(td.trackId, {
29472
+ className: td.className,
29473
+ firstSeenMs: timestamp,
29474
+ lastSeenMs: timestamp,
29475
+ bbox: { ...td.bbox },
29476
+ framesMatched: matched
29477
+ });
29478
+ else {
29479
+ existing.className = td.className;
29480
+ existing.lastSeenMs = timestamp;
29481
+ existing.framesMatched += matched;
29482
+ }
29483
+ }
29484
+ }
29485
+ return { tracks: [...accum.values()].map((t) => ({
29486
+ className: t.className,
29487
+ firstSeenMs: t.firstSeenMs,
29488
+ lastSeenMs: t.lastSeenMs,
29489
+ bbox: t.bbox,
29490
+ framesMatched: t.framesMatched
29491
+ })) };
29492
+ }
29493
+ //#endregion
29386
29494
  //#region src/pipeline-analytics/pipeline/semantic-detail-bbox.ts
29387
29495
  /** Which box the semantic best-shot should crop: a plate/face DETAIL box
29388
29496
  * when we have one, otherwise the subject box. */
@@ -31799,11 +31907,13 @@ var MediaStore = class {
31799
31907
  store;
31800
31908
  logger;
31801
31909
  writeGate;
31910
+ resolveEventMediaLocation;
31802
31911
  constructor(deps) {
31803
31912
  this.storage = deps.storage;
31804
31913
  this.store = deps.store;
31805
31914
  this.logger = deps.logger;
31806
31915
  this.writeGate = deps.writeGate;
31916
+ this.resolveEventMediaLocation = deps.resolveEventMediaLocation;
31807
31917
  }
31808
31918
  gated(mutation) {
31809
31919
  return this.writeGate ? this.writeGate.run(mutation) : mutation();
@@ -31824,7 +31934,8 @@ var MediaStore = class {
31824
31934
  async putUnchecked(params) {
31825
31935
  const key = buildKey(params);
31826
31936
  const path = buildPath(params);
31827
- const location = locationForOwnerKind(params.ownerKind);
31937
+ const staticLocation = locationForOwnerKind(params.ownerKind);
31938
+ const location = staticLocation === "eventMedia" && this.resolveEventMediaLocation ? await this.resolveEventMediaLocation(params.deviceId) : staticLocation;
31828
31939
  const record = {
31829
31940
  deviceId: params.deviceId,
31830
31941
  ownerKind: params.ownerKind,
@@ -41478,6 +41589,21 @@ var MediaCaptureLogAggregator = class {
41478
41589
  }
41479
41590
  };
41480
41591
  //#endregion
41592
+ //#region src/pipeline-analytics/media-locations.ts
41593
+ /**
41594
+ * Picks the location a new `eventMedia` blob should be written to, or `null`
41595
+ * when none is writable — the caller MUST refuse the write rather than fall
41596
+ * back to a read-only location, which would fail far downstream as an opaque
41597
+ * filesystem error instead of a named storage decision.
41598
+ */
41599
+ function pickWritableMediaLocation(locations, currentDefaultId) {
41600
+ const writable = locations.filter((l) => l.enabled && !l.readOnly);
41601
+ if (writable.length === 0) return null;
41602
+ const keep = writable.find((l) => l.id === currentDefaultId);
41603
+ if (keep) return keep.id;
41604
+ return writable.toSorted((a, b) => b.headroomBytes - a.headroomBytes)[0]?.id ?? null;
41605
+ }
41606
+ //#endregion
41481
41607
  //#region src/pipeline-analytics/media-relocate-engine.ts
41482
41608
  var PAGE_SIZE = 200;
41483
41609
  var DEFAULT_THROTTLE_MBPS = 40;
@@ -47548,6 +47674,37 @@ var FaceStore = class {
47548
47674
  }
47549
47675
  }
47550
47676
  /**
47677
+ * Persist a `face.rescoreTrack` result onto the SUGGESTED tier — the ONE
47678
+ * write that explicitly-requested action makes. `suggestion: null` means
47679
+ * the recompute found no candidate worth offering and CLEARS both columns
47680
+ * (a genuine "the gallery has nothing for this face right now" result, not
47681
+ * a no-op left at whatever they held before). Never touches `assigned` /
47682
+ * `recognizedIdentityId` — a suggestion is never an assignment. The caller
47683
+ * is responsible for never calling this with a non-comparable (`model-
47684
+ * mismatch`) report; this method persists whatever it is given.
47685
+ */
47686
+ async setSuggestion(faceId, suggestion) {
47687
+ const data = suggestion === null ? {
47688
+ suggestedIdentityId: null,
47689
+ suggestedMatchScore: null
47690
+ } : {
47691
+ suggestedIdentityId: suggestion.identityId,
47692
+ suggestedMatchScore: suggestion.score
47693
+ };
47694
+ try {
47695
+ await this.store.update.mutate({
47696
+ collection: FACES_COLLECTION,
47697
+ id: faceId,
47698
+ data
47699
+ });
47700
+ } catch (err) {
47701
+ this.logger.warn("FaceStore.setSuggestion failed", { meta: {
47702
+ faceId,
47703
+ error: String(err)
47704
+ } });
47705
+ }
47706
+ }
47707
+ /**
47551
47708
  * `TrackScopedStore` contract for the retention cascade: delete the buffered
47552
47709
  * face rows of the given tracks that are NOT assigned. Reuses the EXACT
47553
47710
  * `assigned` exemption that `prune`/`pruneAll` apply (see the
@@ -56971,6 +57128,144 @@ function rowFromMetadata(id, metadata) {
56971
57128
  };
56972
57129
  }
56973
57130
  //#endregion
57131
+ //#region src/pipeline-analytics/store/settings-raw-mirror.ts
57132
+ /** How long a mirrored entry is served before a refresh is started behind the
57133
+ * next reader. See the module doc for why this matches the per-kind caches'
57134
+ * own window rather than introducing a second staleness bound. */
57135
+ var RAW_SETTINGS_STALE_MS = 5e3;
57136
+ /**
57137
+ * How long a FAILED refresh is not retried.
57138
+ *
57139
+ * A refresh that throws leaves `fetchedAt` where it was, so without a
57140
+ * cooldown every subsequent reader would start another attempt — a broken
57141
+ * settings store would be hammered at frame rate by the very mechanism built
57142
+ * to stop that.
57143
+ */
57144
+ var RAW_SETTINGS_MISS_COOLDOWN_MS = 5e3;
57145
+ /**
57146
+ * The addon's per-device raw blob (`pipeline-analytics:addon-devices`), kept
57147
+ * fresh OFF every per-kind settings cache's hot path.
57148
+ *
57149
+ * `resolve` is COLD-awaiting and single-flight: a burst of settings kinds
57150
+ * (or frames) missing on the same device in the same tick costs ONE read,
57151
+ * not one per caller — the exact failure this class exists to remove.
57152
+ */
57153
+ var RawDeviceBlobMirror = class {
57154
+ deps;
57155
+ staleMs;
57156
+ missCooldownMs;
57157
+ entries = /* @__PURE__ */ new Map();
57158
+ inFlight = /* @__PURE__ */ new Map();
57159
+ now;
57160
+ constructor(deps, staleMs = RAW_SETTINGS_STALE_MS, missCooldownMs = RAW_SETTINGS_MISS_COOLDOWN_MS) {
57161
+ this.deps = deps;
57162
+ this.staleMs = staleMs;
57163
+ this.missCooldownMs = missCooldownMs;
57164
+ this.now = deps.now ?? (() => Date.now());
57165
+ }
57166
+ /**
57167
+ * The device's raw stored blob.
57168
+ *
57169
+ * COLD awaits — the first settings kind to touch a device has to get the
57170
+ * real answer. WARM returns immediately, stale or not, and starts the
57171
+ * refresh behind the caller.
57172
+ */
57173
+ async resolve(deviceId) {
57174
+ const entry = this.entries.get(deviceId);
57175
+ if (entry === void 0) return this.load(deviceId);
57176
+ if (this.now() - entry.fetchedAt >= this.staleMs) this.load(deviceId);
57177
+ return entry.value;
57178
+ }
57179
+ /** Drop a device's entry. Called by every in-process writer of this
57180
+ * device's row, so an operator's own change is never served stale. */
57181
+ invalidate(deviceId) {
57182
+ this.entries.delete(deviceId);
57183
+ }
57184
+ invalidateAll() {
57185
+ this.entries.clear();
57186
+ }
57187
+ async load(deviceId) {
57188
+ const pending = this.inFlight.get(deviceId);
57189
+ if (pending !== void 0) return pending;
57190
+ const run = this.deps.read(deviceId).then((value) => {
57191
+ this.entries.set(deviceId, {
57192
+ value,
57193
+ fetchedAt: this.now()
57194
+ });
57195
+ return value;
57196
+ }).catch((err) => {
57197
+ this.deps.onRefreshFailed?.(deviceId, err);
57198
+ const existing = this.entries.get(deviceId);
57199
+ if (existing !== void 0) {
57200
+ existing.fetchedAt = this.now() - this.staleMs + this.missCooldownMs;
57201
+ return existing.value;
57202
+ }
57203
+ return {};
57204
+ }).finally(() => {
57205
+ this.inFlight.delete(deviceId);
57206
+ });
57207
+ this.inFlight.set(deviceId, run);
57208
+ return run;
57209
+ }
57210
+ };
57211
+ /**
57212
+ * The addon's ONE `addon_settings` row (`pipeline-analytics:addon-settings`),
57213
+ * kept fresh OFF every per-kind settings cache's hot path. Same shape as
57214
+ * {@link RawDeviceBlobMirror}, singleton rather than keyed — there is exactly
57215
+ * one addon store per process.
57216
+ */
57217
+ var RawGlobalStoreMirror = class {
57218
+ deps;
57219
+ staleMs;
57220
+ missCooldownMs;
57221
+ entry = null;
57222
+ inFlight = null;
57223
+ now;
57224
+ constructor(deps, staleMs = RAW_SETTINGS_STALE_MS, missCooldownMs = RAW_SETTINGS_MISS_COOLDOWN_MS) {
57225
+ this.deps = deps;
57226
+ this.staleMs = staleMs;
57227
+ this.missCooldownMs = missCooldownMs;
57228
+ this.now = deps.now ?? (() => Date.now());
57229
+ }
57230
+ async resolve() {
57231
+ const entry = this.entry;
57232
+ if (entry === null) return this.load();
57233
+ if (this.now() - entry.fetchedAt >= this.staleMs) this.load();
57234
+ return entry.value;
57235
+ }
57236
+ /** Drop the mirrored store. Called by every in-process global-settings
57237
+ * writer (`onConfigChanged`), so an operator's own change is never served
57238
+ * stale. */
57239
+ invalidate() {
57240
+ this.entry = null;
57241
+ }
57242
+ async load() {
57243
+ if (this.inFlight !== null) return this.inFlight;
57244
+ const run = this.deps.read().then((value) => {
57245
+ this.entry = {
57246
+ value,
57247
+ fetchedAt: this.now()
57248
+ };
57249
+ return value;
57250
+ }).catch((err) => {
57251
+ this.deps.onRefreshFailed?.(err);
57252
+ const existing = this.entry;
57253
+ if (existing !== null) {
57254
+ this.entry = {
57255
+ value: existing.value,
57256
+ fetchedAt: this.now() - this.staleMs + this.missCooldownMs
57257
+ };
57258
+ return existing.value;
57259
+ }
57260
+ return {};
57261
+ }).finally(() => {
57262
+ this.inFlight = null;
57263
+ });
57264
+ this.inFlight = run;
57265
+ return run;
57266
+ }
57267
+ };
57268
+ //#endregion
56974
57269
  //#region src/pipeline-analytics/track-retention-sweep.ts
56975
57270
  /**
56976
57271
  * Sweep every device with persisted tracks: skip retention-disabled devices,
@@ -58027,6 +58322,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
58027
58322
  /** Event-media relocation engine (entity-routing Phase 4). */
58028
58323
  mediaRelocate = null;
58029
58324
  mediaLocationStorage = null;
58325
+ /**
58326
+ * Cached pick for NEW `eventMedia` writes (`pickWritableMediaLocation`).
58327
+ * Resolved once, never per blob (no-RPC-per-blob, same discipline as
58328
+ * `mediaLocationStorage`'s own root cache) — dropped together with it by
58329
+ * `refreshStorageLocationsForMigration`, since a location's `readOnly` /
58330
+ * `enabled` flags only change via operator reconfig.
58331
+ */
58332
+ eventMediaWriteLocationCache = null;
58030
58333
  mediaWriteGate = new MediaWriteGate();
58031
58334
  storageMigrationLeaseId = null;
58032
58335
  /** Per-camera history of LINKED-device sensor state changes (Part B). */
@@ -58426,6 +58729,33 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
58426
58729
  audioEpisodeTracks = /* @__PURE__ */ new Map();
58427
58730
  audioEpisodeLastPersistMs = /* @__PURE__ */ new Map();
58428
58731
  settingsCaches = new DeviceSettingsCacheHub();
58732
+ /**
58733
+ * The addon-level store and per-device blob, mirrored ONCE and shared by
58734
+ * every settings kind above — see `store/settings-raw-mirror.ts`. Before
58735
+ * this, each kind's cache miss called `resolveGlobalStore()` +
58736
+ * `readDeviceBlob()` directly, so eight kinds expiring in the same window
58737
+ * paid for the same two reads eight times: `get
58738
+ * pipeline-analytics:addon-settings` at 547–694/min, `get
58739
+ * pipeline-analytics:addon-devices` at ~700/min, live 2026-08-26. Invalidated
58740
+ * by every in-process writer: `applyDeviceSettingsPatch` and the retired-
58741
+ * setting purges drop the device mirror, `onConfigChanged` (this process's
58742
+ * own `updateGlobalSettings`) drops the global mirror.
58743
+ */
58744
+ rawDeviceBlobMirror = new RawDeviceBlobMirror({
58745
+ read: (deviceId) => this.readDeviceBlob(deviceId),
58746
+ onRefreshFailed: (deviceId, err) => {
58747
+ this.ctx.logger.debug("raw device-blob mirror refresh failed — serving last value", {
58748
+ tags: { deviceId },
58749
+ meta: { error: err instanceof Error ? err.message : String(err) }
58750
+ });
58751
+ }
58752
+ });
58753
+ rawGlobalStoreMirror = new RawGlobalStoreMirror({
58754
+ read: () => this.resolveGlobalStore(),
58755
+ onRefreshFailed: (err) => {
58756
+ this.ctx.logger.debug("raw global-store mirror refresh failed — serving last value", { meta: { error: err instanceof Error ? err.message : String(err) } });
58757
+ }
58758
+ });
58429
58759
  /** Avoids a store read every ~30 Hz audio frame. */
58430
58760
  audioSettingsCache = this.settingsCaches.registerDeviceCache("audio-detection", SETTINGS_CACHE_TTL_MS);
58431
58761
  sensitivitySettingsCache = this.settingsCaches.registerDeviceCache("detection-sensitivity", SETTINGS_CACHE_TTL_MS);
@@ -59145,6 +59475,53 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
59145
59475
  this.mediaLocationStorage = routed;
59146
59476
  return routed;
59147
59477
  }
59478
+ /**
59479
+ * Resolves which `eventMedia` location a NEW footage-class blob should be
59480
+ * written to (`MediaStoreDeps.resolveEventMediaLocation`). Reflects
59481
+ * `config.readOnly` — an operator draining a location marks it read-only,
59482
+ * and new writes must never land there (the relocate mover, exposed as
59483
+ * `relocateMedia`, empties what is already on it in the background).
59484
+ *
59485
+ * Cached after the first successful resolution — this is a per-blob write
59486
+ * path, and the write-side provider is a declared no-RPC-per-blob bypass
59487
+ * (D-note in `location-aware-media-storage.ts`); re-querying the storage
59488
+ * cap on every blob would undo that. Dropped by
59489
+ * `refreshStorageLocationsForMigration`, same trigger as
59490
+ * `mediaLocationStorage.invalidateRoots()`.
59491
+ *
59492
+ * Degrades to the bare `eventMedia` type ref (pre-2026-08-26 behaviour)
59493
+ * when the storage cap itself is unreachable — a transient RPC failure (or
59494
+ * a test harness that never wires `api.storage`) must not turn every media
59495
+ * write into a hard failure the way "no writable location" does.
59496
+ */
59497
+ async resolveEventMediaWriteLocation(api, logger, deviceId) {
59498
+ const cached = this.eventMediaWriteLocationCache;
59499
+ if (cached !== null) return cached;
59500
+ let locations;
59501
+ try {
59502
+ locations = await api.storage.listLocations.query({ type: DEFAULT_MEDIA_LOCATION });
59503
+ } catch (err) {
59504
+ logger.debug("pipeline-analytics: eventMedia location list unavailable — writing through the default ref", { meta: { error: errMsg(err) } });
59505
+ this.eventMediaWriteLocationCache = DEFAULT_MEDIA_LOCATION;
59506
+ return DEFAULT_MEDIA_LOCATION;
59507
+ }
59508
+ const picked = pickWritableMediaLocation(locations.map((l) => ({
59509
+ id: l.id,
59510
+ readOnly: l.config["readOnly"] === true,
59511
+ enabled: l.enabled !== false,
59512
+ headroomBytes: l.capacity?.availableBytes ?? 0
59513
+ })), locations.find((l) => l.isDefault)?.id ?? "eventMedia");
59514
+ if (picked === null) {
59515
+ const seen = locations.length > 0 ? locations.map((l) => l.id).join(", ") : "(none registered)";
59516
+ logger.error("pipeline-analytics: no writable eventMedia storage location — refusing the write instead of failing far downstream as a filesystem error", {
59517
+ tags: { deviceId },
59518
+ meta: { locationsSeen: seen }
59519
+ });
59520
+ throw new Error(`no writable eventMedia storage location (seen: ${seen})`);
59521
+ }
59522
+ this.eventMediaWriteLocationCache = picked;
59523
+ return picked;
59524
+ }
59148
59525
  /** Constructs every SQLite-backed store plus the stationary/package-drop/
59149
59526
  * sensor plumbing, in the exact pre-S7 order. Returns the non-null bundle
59150
59527
  * so the rest of boot consumes the stores without re-narrowing. */
@@ -59203,7 +59580,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
59203
59580
  storage,
59204
59581
  store: api.settingsStore,
59205
59582
  logger: logger.child("MediaStore"),
59206
- writeGate: this.mediaWriteGate
59583
+ writeGate: this.mediaWriteGate,
59584
+ resolveEventMediaLocation: (deviceId) => this.resolveEventMediaWriteLocation(api, logger, deviceId)
59207
59585
  });
59208
59586
  this.mediaStore = mediaStore;
59209
59587
  const retrainFrames = new RetrainFrameStore({
@@ -60236,6 +60614,21 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
60236
60614
  ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {}
60237
60615
  });
60238
60616
  }
60617
+ /**
60618
+ * The FrameProcessor pass of a replay run — see the cap doc on
60619
+ * `runReplayFrameProcessor` for why this is not the `processFrame` method
60620
+ * this addon deliberately does not have.
60621
+ *
60622
+ * `runFrameProcessorPass` is pure w.r.t. this addon's own state: it
60623
+ * constructs a FRESH `FrameProcessor` and does not touch `this.trackStore`,
60624
+ * `this.stationaryRegistry` or any other instance field — unlike
60625
+ * `getOrCreateProcessor` (the production path), which wires both.
60626
+ * `scripts/check-replay-isolation.ts` holds that boundary on the
60627
+ * `replay/` directory this delegates into.
60628
+ */
60629
+ async runReplayFrameProcessor(input) {
60630
+ return runFrameProcessorPass(input);
60631
+ }
60239
60632
  async listRetrainAnnotations(input) {
60240
60633
  return this.requireRetrain().listAnnotations(input.trackId);
60241
60634
  }
@@ -60511,6 +60904,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
60511
60904
  this.forgetDeviceProcessors(data.deviceId);
60512
60905
  this.levelStateByDevice.delete(data.deviceId);
60513
60906
  this.settingsCaches.clearDevice(data.deviceId);
60907
+ this.rawDeviceBlobMirror.invalidate(data.deviceId);
60514
60908
  }
60515
60909
  }
60516
60910
  /** `DeviceUnregistered` handler — subscription topology lives in
@@ -60535,6 +60929,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
60535
60929
  }
60536
60930
  this.audioMarkers?.forgetDevice(deviceId);
60537
60931
  this.settingsCaches.clearDevice(deviceId);
60932
+ this.rawDeviceBlobMirror.invalidate(deviceId);
60538
60933
  this.bindingCache?.invalidate(deviceId);
60539
60934
  this.detectionCoverage?.forget(deviceId);
60540
60935
  this.zoneAnalytics?.forgetDevice(deviceId);
@@ -60963,6 +61358,17 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
60963
61358
  }] : []
60964
61359
  ];
60965
61360
  }
61361
+ /**
61362
+ * `BaseAddon.updateGlobalSettings` calls this after the write lands —
61363
+ * gated to the LOCAL node's own write (a sibling-node write returns before
61364
+ * reaching it), which is exactly the scope `rawGlobalStoreMirror` needs to
61365
+ * invalidate: the mirror only ever answers for THIS process, so it only
61366
+ * needs to drop on THIS process's own write (D224).
61367
+ */
61368
+ async onConfigChanged() {
61369
+ await super.onConfigChanged();
61370
+ this.rawGlobalStoreMirror.invalidate();
61371
+ }
60966
61372
  async onShutdown() {
60967
61373
  this.shuttingDown = true;
60968
61374
  this.sceneEngine?.stop();
@@ -61018,6 +61424,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
61018
61424
  this.gatedNodeByDevice.clear();
61019
61425
  this.levelStateByDevice.clear();
61020
61426
  this.settingsCaches.clearAll();
61427
+ this.rawDeviceBlobMirror.invalidateAll();
61428
+ this.rawGlobalStoreMirror.invalidate();
61021
61429
  this.confirmationDetectorStepByNode.clear();
61022
61430
  this.trackStore?.clearAll();
61023
61431
  this.stationaryRegistry = null;
@@ -61730,10 +62138,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
61730
62138
  * auto-assigning — see `__tests__/global-settings-cascade.spec.ts`).
61731
62139
  * `resolveGlobalStore()` is the sanctioned BaseAddon read (per-node
61732
62140
  * projection applied; a raw `readAddonStore` would leak `@node` keys if a
61733
- * perNode field is ever declared). */
62141
+ * perNode field is ever declared).
62142
+ *
62143
+ * Both raw reads go through `rawGlobalStoreMirror` / `rawDeviceBlobMirror`
62144
+ * rather than straight to the store: this method is the ONE place all
62145
+ * eight settings kinds funnel through on a cache miss, so calling the raw
62146
+ * reads directly here means eight kinds expiring in the same window each
62147
+ * pay for the same two reads — see `store/settings-raw-mirror.ts`. */
61734
62148
  async readDeviceSettings(deviceId, resolve) {
61735
- const globalRaw = this.ctxIfReady !== null ? await this.resolveGlobalStore() : {};
61736
- const deviceRaw = await this.readDeviceBlob(deviceId);
62149
+ const globalRaw = this.ctxIfReady !== null ? await this.rawGlobalStoreMirror.resolve() : {};
62150
+ const deviceRaw = await this.rawDeviceBlobMirror.resolve(deviceId);
61737
62151
  return resolve({
61738
62152
  ...globalRaw,
61739
62153
  ...deviceRaw
@@ -63404,6 +63818,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
63404
63818
  });
63405
63819
  }
63406
63820
  await settings.writeDeviceStore(deviceId, plan.patch);
63821
+ this.rawDeviceBlobMirror.invalidate(deviceId);
63407
63822
  } catch (err) {
63408
63823
  this.retiredSettingsChecked.delete(deviceId);
63409
63824
  this.ctx.logger.warn("retired retention settings migration failed — will retry", {
@@ -63475,6 +63890,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
63475
63890
  deviceId
63476
63891
  });
63477
63892
  await settings.writeDeviceStore(deviceId, plan.patch);
63893
+ this.rawDeviceBlobMirror.invalidate(deviceId);
63478
63894
  } catch (err) {
63479
63895
  this.storedSuggestionCheckedDevices.delete(deviceId);
63480
63896
  this.ctx.logger.warn("face suggestion threshold purge failed — will retry", {
@@ -65162,6 +65578,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65162
65578
  * See `face-rescore.ts` for why the number already on the track cannot answer
65163
65579
  * that: it was computed during live detection and persisted at track close,
65164
65580
  * so every enrolment made since is invisible to it.
65581
+ *
65582
+ * The report's result is PERSISTED (`persistRescoreResult`, below) onto
65583
+ * `Face.suggestedIdentityId` / `suggestedMatchScore` — the SAME columns the
65584
+ * live recognizer's suggestion band writes, so a reader never has to know
65585
+ * whether a suggestion came from track close or from this button. Still
65586
+ * never a background job: this write happens once, only because the
65587
+ * operator asked THIS question right now.
65165
65588
  */
65166
65589
  async rescoreFaceForTrack(input) {
65167
65590
  const api = this.ctx.api;
@@ -65204,6 +65627,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65204
65627
  clusterModelId,
65205
65628
  params
65206
65629
  });
65630
+ const persisted = await persistRescoreResult(faceId, report, { setSuggestion: (id, suggestion) => faceStore.setSuggestion(id, suggestion) });
65207
65631
  this.ctx.logger.info("face rescore", {
65208
65632
  tags: { deviceId: input.deviceId },
65209
65633
  meta: {
@@ -65217,7 +65641,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65217
65641
  best: report.identities[0]?.name ?? null,
65218
65642
  bestScore: report.identities[0]?.score ?? null,
65219
65643
  storedSuggestion: face.suggestedMatchScore ?? null,
65220
- reembedded: reembed.succeeded
65644
+ reembedded: reembed.succeeded,
65645
+ persisted
65221
65646
  }
65222
65647
  });
65223
65648
  return {
@@ -65621,6 +66046,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65621
66046
  async refreshStorageLocationsForMigration(input) {
65622
66047
  if (this.storageMigrationLeaseId !== input.leaseId) throw new Error("event-media storage migration lease does not match");
65623
66048
  this.mediaLocationStorage?.invalidateRoots();
66049
+ this.eventMediaWriteLocationCache = null;
65624
66050
  return { refreshed: true };
65625
66051
  }
65626
66052
  async startStorageMigrationMove(input) {
@@ -65634,6 +66060,32 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65634
66060
  async cancelStorageMigrationMove(input) {
65635
66061
  return { cancelled: this.mediaRelocate?.cancel(input.jobId) ?? false };
65636
66062
  }
66063
+ /**
66064
+ * Move event media between locations — the OPERATOR's mover, no lease.
66065
+ * Twin of `recording.relocateFootage`: same engine (`this.mediaRelocate`)
66066
+ * the lease-gated coordinator move uses, runnable in the background
66067
+ * without pausing anything. `startStorageMigrationMove` above stays
66068
+ * lease-gated for the orchestrated migration; this method must never touch
66069
+ * `storageMigrationLeaseId`.
66070
+ */
66071
+ async relocateMedia(input) {
66072
+ if (!this.mediaRelocate) throw new Error("media relocation unavailable");
66073
+ const jobId = this.mediaRelocate.start(input);
66074
+ this.ctx.logger.info("pipeline-analytics: media relocate job enqueued", { meta: {
66075
+ jobId,
66076
+ to: input.toLocationId
66077
+ } });
66078
+ return { jobId };
66079
+ }
66080
+ /** Every relocate job this addon knows about, newest first (in RAM: the
66081
+ * move is resumable, so a lost list costs nothing but the display). */
66082
+ async listRelocateMediaJobs() {
66083
+ return this.mediaRelocate?.list() ?? [];
66084
+ }
66085
+ /** Cancel a running or queued relocate job. */
66086
+ async cancelRelocateMedia(input) {
66087
+ return { cancelled: this.mediaRelocate?.cancel(input.jobId) ?? false };
66088
+ }
65637
66089
  async listOpsLog(input) {
65638
66090
  return this.queryFacade.listOpsLog(input);
65639
66091
  }
@@ -66027,6 +66479,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66027
66479
  }
66028
66480
  async applyDeviceSettingsPatch(input) {
66029
66481
  await this.updateDeviceSettings(input.deviceId, input.patch);
66482
+ this.rawDeviceBlobMirror.invalidate(input.deviceId);
66030
66483
  this.audioSettingsCache.delete(input.deviceId);
66031
66484
  this.sensitivitySettingsCache.delete(input.deviceId);
66032
66485
  this.trackingSettingsCache.delete(input.deviceId);