@camstack/addon-post-analysis 1.1.24 → 1.1.25

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.
@@ -10585,6 +10585,7 @@ var PipelineAddonSchemaSchema = object({
10585
10585
  defaultModelId: string(),
10586
10586
  defaultModelIdByFormat: record(string(), string()).optional(),
10587
10587
  enabledByDefault: boolean().optional(),
10588
+ backfillIntoExistingOverrides: boolean().optional(),
10588
10589
  defaultConfidence: number(),
10589
10590
  group: string().optional(),
10590
10591
  configSchema: array(ConfigFieldBridge).readonly().optional()
@@ -10563,6 +10563,7 @@ var PipelineAddonSchemaSchema = object({
10563
10563
  defaultModelId: string(),
10564
10564
  defaultModelIdByFormat: record(string(), string()).optional(),
10565
10565
  enabledByDefault: boolean().optional(),
10566
+ backfillIntoExistingOverrides: boolean().optional(),
10566
10567
  defaultConfidence: number(),
10567
10568
  group: string().optional(),
10568
10569
  configSchema: array(ConfigFieldBridge).readonly().optional()
@@ -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-B0VgBdBN.js");
5
+ const require_dist = require("../dist-CCC79h7t.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_fs$1 = require_dist.__toESM(node_fs, 1);
8
8
  node_fs = require_dist.__toESM(node_fs);
@@ -1,4 +1,4 @@
1
- import { a as embeddingEncoderCapability, p as BaseAddon, s as hfModelUrl } from "../dist-DRK78NMq.mjs";
1
+ import { a as embeddingEncoderCapability, p as BaseAddon, s as hfModelUrl } from "../dist-Csk_yJr_.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import * as fs from "node:fs";
4
4
  import * as path$1 from "node:path";
@@ -2,8 +2,8 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-B0VgBdBN.js");
6
- const require_resolve_frame = require("../resolve-frame-DBXwF5fk.js");
5
+ const require_dist = require("../dist-CCC79h7t.js");
6
+ const require_resolve_frame = require("../resolve-frame-sKYbstL-.js");
7
7
  let _camstack_shm_ring = require("@camstack/shm-ring");
8
8
  //#region src/enrichment-engine/types.ts
9
9
  var DEFAULT_ENRICHMENT_CONFIG = {
@@ -1,4 +1,4 @@
1
- import { C as object, S as number, T as tuple, _ as createEvent, b as array, g as asJsonObject, h as EventCategory, p as BaseAddon, w as string, x as boolean, y as _enum } from "../dist-DRK78NMq.mjs";
1
+ import { C as object, S as number, T as tuple, _ as createEvent, b as array, g as asJsonObject, h as EventCategory, p as BaseAddon, w as string, x as boolean, y as _enum } from "../dist-Csk_yJr_.mjs";
2
2
  import { n as extractCrop, t as resolveFrame } from "../resolve-frame-CT1T1tWy.mjs";
3
3
  import { FrameRingReaderCache } from "@camstack/shm-ring";
4
4
  //#region src/enrichment-engine/types.ts
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-B0VgBdBN.js");
1
+ const require_dist = require("./dist-CCC79h7t.js");
2
2
  let node_fs = require("node:fs");
3
3
  node_fs = require_dist.__toESM(node_fs, 1);
4
4
  let node_path = require("node:path");
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.1.40",
21
+ version: "1.1.41",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_pipeline_analytics_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.1.32",
36
+ version: "1.1.33",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_pipeline_analytics_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.1.40",
39
+ version: "1.1.41",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -81,7 +81,7 @@ async function r() {
81
81
  }
82
82
  },
83
83
  "@camstack/ui-library": {
84
- version: "1.1.32",
84
+ version: "1.1.33",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,
@@ -2,8 +2,8 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-B0VgBdBN.js");
6
- const require_resolve_frame = require("../resolve-frame-DBXwF5fk.js");
5
+ const require_dist = require("../dist-CCC79h7t.js");
6
+ const require_resolve_frame = require("../resolve-frame-sKYbstL-.js");
7
7
  let _camstack_shm_ring = require("@camstack/shm-ring");
8
8
  let sharp = require("sharp");
9
9
  sharp = require_dist.__toESM(sharp);
@@ -1532,6 +1532,43 @@ var FrameProcessor = class {
1532
1532
  }
1533
1533
  };
1534
1534
  //#endregion
1535
+ //#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
1536
+ var BestDetectionTracker = class {
1537
+ hysteresis;
1538
+ minGapMs;
1539
+ best = /* @__PURE__ */ new Map();
1540
+ constructor(options = {}) {
1541
+ this.hysteresis = options.hysteresis ?? 0;
1542
+ this.minGapMs = options.minGapMs ?? 0;
1543
+ }
1544
+ /**
1545
+ * Record a detection's `confidence` (at wall-clock `timestamp`) for `trackId`.
1546
+ * Returns true when it becomes the track's new best — the first sighting, or a
1547
+ * confidence that beats the held peak by more than `hysteresis` AND respects
1548
+ * `minGapMs`. On acceptance the held peak is advanced to this observation.
1549
+ */
1550
+ observe(trackId, confidence, timestamp) {
1551
+ const cur = this.best.get(trackId);
1552
+ const isNewBest = cur === void 0 || confidence > cur.confidence + this.hysteresis && timestamp - cur.atMs >= this.minGapMs;
1553
+ if (isNewBest) this.best.set(trackId, {
1554
+ confidence,
1555
+ atMs: timestamp
1556
+ });
1557
+ return isNewBest;
1558
+ }
1559
+ /** The held peak for a track (undefined if never observed). */
1560
+ peak(trackId) {
1561
+ return this.best.get(trackId);
1562
+ }
1563
+ /** Drop a track's peak (call at track end). */
1564
+ delete(trackId) {
1565
+ this.best.delete(trackId);
1566
+ }
1567
+ clear() {
1568
+ this.best.clear();
1569
+ }
1570
+ };
1571
+ //#endregion
1535
1572
  //#region src/pipeline-analytics/pipeline/native-detection.ts
1536
1573
  /**
1537
1574
  * Nominal frame size used to denormalize native `[0,1]` boxes when a
@@ -1801,6 +1838,16 @@ var TrackStore = class {
1801
1838
  lastSnapshotAt(trackId) {
1802
1839
  return this.active.get(trackId)?.lastSnapshotAt ?? 0;
1803
1840
  }
1841
+ /**
1842
+ * Seed the snapshot cadence clock (once, at track start) WITHOUT appending a
1843
+ * snapshot, so the first periodic snapshot fires ~snapshotIntervalMs after the
1844
+ * track begins rather than immediately — the `firstFrame` already covers the
1845
+ * track's start. No-op if a snapshot was already taken (clock already set).
1846
+ */
1847
+ seedSnapshotClock(trackId, timestamp) {
1848
+ const t = this.active.get(trackId);
1849
+ if (t && t.lastSnapshotAt === 0) t.lastSnapshotAt = timestamp;
1850
+ }
1804
1851
  getActive(deviceId) {
1805
1852
  const out = [];
1806
1853
  for (const t of this.active.values()) if (t.deviceId === deviceId && t.active) out.push(cloneTrack(t));
@@ -2065,6 +2112,46 @@ var MediaStore = class {
2065
2112
  }
2066
2113
  }
2067
2114
  /**
2115
+ * Overwrite semantics: keep exactly ONE media entry per (ownerKind, ownerId,
2116
+ * kind). Used for the rolling `lastFrame` and the best `thumbnail` per track —
2117
+ * each new capture replaces the previous one (blob + index row) rather than
2118
+ * accumulating a filmstrip the way `put` does. Deletes any existing rows of
2119
+ * that (owner, kind) first, then writes the fresh one. Returns the new key.
2120
+ */
2121
+ async putReplacing(params) {
2122
+ const existing = await this.store.query.query({
2123
+ collection: MEDIA_COLLECTION,
2124
+ filter: { where: {
2125
+ ownerKind: params.ownerKind,
2126
+ ownerId: params.ownerId,
2127
+ kind: params.kind
2128
+ } }
2129
+ });
2130
+ const newKey = await this.put(params);
2131
+ for (const row of existing) {
2132
+ if (row.id === newKey) continue;
2133
+ const path = String(row.data["path"] ?? "");
2134
+ if (path) try {
2135
+ await this.storage.delete({
2136
+ location: "eventMedia",
2137
+ relativePath: path
2138
+ });
2139
+ } catch {}
2140
+ try {
2141
+ await this.store.delete.mutate({
2142
+ collection: MEDIA_COLLECTION,
2143
+ key: row.id
2144
+ });
2145
+ } catch (err) {
2146
+ this.logger.debug("media putReplacing: stale row delete failed", { meta: {
2147
+ key: row.id,
2148
+ error: String(err)
2149
+ } });
2150
+ }
2151
+ }
2152
+ return newKey;
2153
+ }
2154
+ /**
2068
2155
  * Fetch one media entry by its key (id). Returns null if the key is not
2069
2156
  * found in the index or if the blob is missing from storage.
2070
2157
  */
@@ -2977,7 +3064,9 @@ var EventMediaDispatcher = class {
2977
3064
  }
2978
3065
  async captureForFrame(input) {
2979
3066
  const { deviceId, frameHandle, events, trackFrames } = input;
2980
- if (events.length === 0 && trackFrames.length === 0) return;
3067
+ const snapshots = input.snapshots ?? [];
3068
+ const empty = { storedSnapshots: [] };
3069
+ if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
2981
3070
  let decoded;
2982
3071
  try {
2983
3072
  decoded = await require_resolve_frame.resolveFrame(frameHandle, {
@@ -2994,7 +3083,7 @@ var EventMediaDispatcher = class {
2994
3083
  error: String(err)
2995
3084
  }
2996
3085
  });
2997
- return;
3086
+ return empty;
2998
3087
  }
2999
3088
  if (!decoded) {
3000
3089
  this.deps.logger.debug("event media: frame recycled before resolve", {
@@ -3004,7 +3093,7 @@ var EventMediaDispatcher = class {
3004
3093
  shmId: frameHandle.shmId
3005
3094
  }
3006
3095
  });
3007
- return;
3096
+ return empty;
3008
3097
  }
3009
3098
  if (decoded.format !== "rgb") {
3010
3099
  this.deps.logger.debug("event media: resolved frame is not RGB", {
@@ -3014,13 +3103,87 @@ var EventMediaDispatcher = class {
3014
3103
  format: decoded.format
3015
3104
  }
3016
3105
  });
3017
- return;
3106
+ return empty;
3018
3107
  }
3019
3108
  const frameData = Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data);
3020
3109
  const fw = decoded.width;
3021
3110
  const fh = decoded.height;
3022
3111
  for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
3023
3112
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
3113
+ const storedSnapshots = [];
3114
+ for (const sn of snapshots) {
3115
+ const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn);
3116
+ if (stored) storedSnapshots.push(stored);
3117
+ }
3118
+ return { storedSnapshots };
3119
+ }
3120
+ /**
3121
+ * Periodic per-track media (§5). Encodes the boxed frame ONCE and fans it out
3122
+ * to whichever of the three destinations is requested: an appended `snapshot`
3123
+ * (timeline filmstrip), the rolling `lastFrame` (overwrite), and the best
3124
+ * `thumbnail` (overwrite). Returns the appended snapshot for TrackStore wiring
3125
+ * (null when `appendSnapshot` is false or the encode failed).
3126
+ */
3127
+ async writeTrackSnapshot(deviceId, frameData, fw, fh, sn) {
3128
+ if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return null;
3129
+ let boxed;
3130
+ try {
3131
+ boxed = await drawBoxedFrame(frameData, fw, fh, [{
3132
+ ...sn.bbox,
3133
+ ...sn.label ? { label: sn.label } : {}
3134
+ }], { quality: MEDIA_QUALITY });
3135
+ } catch (err) {
3136
+ this.deps.logger.warn("event media: track snapshot encode failed", {
3137
+ tags: { deviceId },
3138
+ meta: {
3139
+ deviceId,
3140
+ trackId: sn.trackId,
3141
+ error: err instanceof Error ? err.message : String(err)
3142
+ }
3143
+ });
3144
+ return null;
3145
+ }
3146
+ let stored = null;
3147
+ if (sn.appendSnapshot) try {
3148
+ const mediaKey = await this.deps.mediaStore.put({
3149
+ deviceId,
3150
+ ownerKind: "track",
3151
+ ownerId: sn.trackId,
3152
+ kind: "snapshot",
3153
+ timestamp: sn.timestamp,
3154
+ data: boxed
3155
+ });
3156
+ stored = {
3157
+ trackId: sn.trackId,
3158
+ mediaKey,
3159
+ timestamp: sn.timestamp,
3160
+ bbox: sn.bbox
3161
+ };
3162
+ } catch {}
3163
+ if (sn.rollingLastFrame) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
3164
+ if (sn.bestThumbnail) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
3165
+ return stored;
3166
+ }
3167
+ async replaceKind(deviceId, trackId, kind, timestamp, data) {
3168
+ try {
3169
+ await this.deps.mediaStore.putReplacing({
3170
+ deviceId,
3171
+ ownerKind: "track",
3172
+ ownerId: trackId,
3173
+ kind,
3174
+ timestamp,
3175
+ data
3176
+ });
3177
+ } catch (err) {
3178
+ this.deps.logger.debug(`event media: ${kind} replace failed`, {
3179
+ tags: { deviceId },
3180
+ meta: {
3181
+ deviceId,
3182
+ trackId,
3183
+ error: err instanceof Error ? err.message : String(err)
3184
+ }
3185
+ });
3186
+ }
3024
3187
  }
3025
3188
  async writeEventMedia(deviceId, frameData, fw, fh, ev, cropPadding) {
3026
3189
  const box = {
@@ -4015,11 +4178,20 @@ function resolveFaceSettings(raw) {
4015
4178
  * its default — parse never throws). Mirrors `face-settings` /
4016
4179
  * `audio-detection-settings`.
4017
4180
  */
4018
- var MediaSettingsSchema = require_dist.object({
4019
- /** Fractional padding added around a detection bbox before cropping.
4020
- * 0 = tight crop, 0.15 = expand each side by 15% of the box dimension,
4021
- * max 2 (200%). */
4022
- cropPadding: require_dist.number().min(0).max(2).default(.15) });
4181
+ var MediaSettingsSchema = require_dist.object({
4182
+ /** Fractional padding added around a detection bbox before cropping.
4183
+ * 0 = tight crop, 0.15 = expand each side by 15% of the box dimension,
4184
+ * max 2 (200%). */
4185
+ cropPadding: require_dist.number().min(0).max(2).default(.15),
4186
+ /** Master switch for periodic per-track snapshots (the timeline filmstrip +
4187
+ * the rolling `lastFrame` + the best `thumbnail`). When false, only the
4188
+ * per-track `firstFrame` and per-event media are produced. */
4189
+ saveThumbnails: require_dist.boolean().default(true),
4190
+ /** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
4191
+ * A snapshot is captured for an active track only after this much wall-clock
4192
+ * has elapsed since its previous one. */
4193
+ snapshotIntervalMs: require_dist.number().int().min(500).max(6e4).default(5e3)
4194
+ });
4023
4195
  var MEDIA_DEFAULTS = MediaSettingsSchema.parse({});
4024
4196
  /**
4025
4197
  * Resolve a per-device store blob into typed media settings. Unknown/invalid
@@ -4030,7 +4202,11 @@ function resolveMediaSettings(raw) {
4030
4202
  const parsed = MediaSettingsSchema.shape[key].safeParse(raw[key]);
4031
4203
  return parsed.success ? parsed.data : MEDIA_DEFAULTS[key];
4032
4204
  };
4033
- return { cropPadding: pick("cropPadding") };
4205
+ return {
4206
+ cropPadding: pick("cropPadding"),
4207
+ saveThumbnails: pick("saveThumbnails"),
4208
+ snapshotIntervalMs: pick("snapshotIntervalMs")
4209
+ };
4034
4210
  }
4035
4211
  //#endregion
4036
4212
  //#region src/pipeline-analytics/store/identity-store.ts
@@ -4926,6 +5102,11 @@ var FaceRecognizer = class {
4926
5102
  names = /* @__PURE__ */ new Map();
4927
5103
  aggregates = /* @__PURE__ */ new Map();
4928
5104
  bestFace = /* @__PURE__ */ new Map();
5105
+ /** The ONE best-detection-per-track policy, shared with the best-frame path
5106
+ * (`index.ts`). Face params: no hysteresis / no rate-limit — always keep the
5107
+ * true highest-confidence face (holding a buffer in memory is cheap, and a
5108
+ * track may last well under the best-frame rate-limit window). */
5109
+ bestTracker = new BestDetectionTracker();
4929
5110
  constructor(deps) {
4930
5111
  this.deps = deps;
4931
5112
  }
@@ -4959,55 +5140,25 @@ var FaceRecognizer = class {
4959
5140
  threshold: settings.similarityThreshold,
4960
5141
  margin: settings.margin
4961
5142
  }) : /* @__PURE__ */ new Map();
5143
+ const labelWork = [];
4962
5144
  for (const c of candidates) {
4963
5145
  const match = matches.get(c.trackId) ?? null;
4964
5146
  const { state, changed } = updateTrackAggregate(this.aggregates.get(c.trackId) ?? EMPTY_AGGREGATE, match, { confirmFrames: settings.confirmFrames });
4965
5147
  this.aggregates.set(c.trackId, state);
4966
- if (changed && state.assignedIdentityId !== null) {
4967
- const name = this.names.get(state.assignedIdentityId);
4968
- this.deps.logger.info("face: identity assigned", {
4969
- tags: {
4970
- deviceId: input.deviceId,
4971
- trackId: c.trackId
4972
- },
4973
- meta: {
4974
- identityId: state.assignedIdentityId,
4975
- name: name ?? null,
4976
- score: match?.score ?? null
4977
- }
4978
- });
4979
- if (name !== void 0) {
4980
- try {
4981
- await this.deps.trackStore.setLabel(c.trackId, name);
4982
- } catch (err) {
4983
- this.deps.logger.warn("setLabel failed", {
4984
- tags: { deviceId: input.deviceId },
4985
- meta: {
4986
- trackId: c.trackId,
4987
- error: String(err)
4988
- }
4989
- });
4990
- }
4991
- try {
4992
- await this.deps.eventStore.setLabelForTrack(c.trackId, name);
4993
- } catch (err) {
4994
- this.deps.logger.warn("setLabelForTrack failed", {
4995
- tags: { deviceId: input.deviceId },
4996
- meta: {
4997
- trackId: c.trackId,
4998
- error: String(err)
4999
- }
5000
- });
5001
- }
5002
- }
5003
- }
5148
+ if (changed && state.assignedIdentityId !== null) labelWork.push({
5149
+ trackId: c.trackId,
5150
+ assignedIdentityId: state.assignedIdentityId,
5151
+ matchScore: match?.score ?? null
5152
+ });
5004
5153
  const recognizedIdentityId = state.assignedIdentityId ?? match?.identityId ?? void 0;
5005
5154
  const held = this.bestFace.get(c.trackId);
5006
- if (held === void 0 || c.confidence > held.score) {
5155
+ const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp);
5156
+ const needsCrop = held !== void 0 && held.crop === void 0;
5157
+ if (isNewBest || needsCrop) {
5007
5158
  const cropBbox = c.faceBbox ?? c.bbox;
5008
5159
  let crop;
5009
- if (input.frameHandle !== void 0) try {
5010
- crop = await this.deps.captureCrop(input.frameHandle, cropBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
5160
+ if (c.faceBbox !== void 0 && input.frameHandle !== void 0) try {
5161
+ crop = await this.deps.captureCrop(input.frameHandle, c.faceBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
5011
5162
  } catch (err) {
5012
5163
  this.deps.logger.debug("FaceRecognizer crop capture failed", {
5013
5164
  tags: { deviceId: input.deviceId },
@@ -5017,14 +5168,20 @@ var FaceRecognizer = class {
5017
5168
  }
5018
5169
  });
5019
5170
  }
5020
- this.bestFace.set(c.trackId, {
5021
- score: c.confidence,
5022
- embedding: c.embedding,
5023
- embeddingModelId: c.embeddingModelId,
5024
- bbox: cropBbox,
5025
- timestamp: input.timestamp,
5026
- ...crop !== void 0 ? { crop } : {},
5027
- ...recognizedIdentityId !== void 0 ? { recognizedIdentityId } : {}
5171
+ if (isNewBest) {
5172
+ const bestCrop = crop ?? held?.crop;
5173
+ this.bestFace.set(c.trackId, {
5174
+ score: c.confidence,
5175
+ embedding: c.embedding,
5176
+ embeddingModelId: c.embeddingModelId,
5177
+ bbox: cropBbox,
5178
+ timestamp: input.timestamp,
5179
+ ...bestCrop !== void 0 ? { crop: bestCrop } : {},
5180
+ ...recognizedIdentityId !== void 0 ? { recognizedIdentityId } : {}
5181
+ });
5182
+ } else if (crop !== void 0 && held !== void 0) this.bestFace.set(c.trackId, {
5183
+ ...held,
5184
+ crop
5028
5185
  });
5029
5186
  this.deps.logger.debug("face: best-face held", {
5030
5187
  tags: {
@@ -5033,15 +5190,53 @@ var FaceRecognizer = class {
5033
5190
  },
5034
5191
  meta: {
5035
5192
  score: c.confidence,
5036
- hasCrop: crop !== void 0,
5193
+ isNewBest,
5194
+ hasCrop: this.bestFace.get(c.trackId)?.crop !== void 0,
5037
5195
  recognizedIdentityId: recognizedIdentityId ?? null
5038
5196
  }
5039
5197
  });
5040
- } else if (recognizedIdentityId !== void 0 && held.recognizedIdentityId === void 0) this.bestFace.set(c.trackId, {
5198
+ } else if (held !== void 0 && recognizedIdentityId !== void 0 && held.recognizedIdentityId === void 0) this.bestFace.set(c.trackId, {
5041
5199
  ...held,
5042
5200
  recognizedIdentityId
5043
5201
  });
5044
5202
  }
5203
+ for (const work of labelWork) {
5204
+ const name = this.names.get(work.assignedIdentityId);
5205
+ this.deps.logger.info("face: identity assigned", {
5206
+ tags: {
5207
+ deviceId: input.deviceId,
5208
+ trackId: work.trackId
5209
+ },
5210
+ meta: {
5211
+ identityId: work.assignedIdentityId,
5212
+ name: name ?? null,
5213
+ score: work.matchScore
5214
+ }
5215
+ });
5216
+ if (name === void 0) continue;
5217
+ try {
5218
+ await this.deps.trackStore.setLabel(work.trackId, name);
5219
+ } catch (err) {
5220
+ this.deps.logger.warn("setLabel failed", {
5221
+ tags: { deviceId: input.deviceId },
5222
+ meta: {
5223
+ trackId: work.trackId,
5224
+ error: String(err)
5225
+ }
5226
+ });
5227
+ }
5228
+ try {
5229
+ await this.deps.eventStore.setLabelForTrack(work.trackId, name);
5230
+ } catch (err) {
5231
+ this.deps.logger.warn("setLabelForTrack failed", {
5232
+ tags: { deviceId: input.deviceId },
5233
+ meta: {
5234
+ trackId: work.trackId,
5235
+ error: String(err)
5236
+ }
5237
+ });
5238
+ }
5239
+ }
5045
5240
  }
5046
5241
  /**
5047
5242
  * Persist the held best face for a finished track as ONE FaceStore buffer
@@ -5052,6 +5247,7 @@ var FaceRecognizer = class {
5052
5247
  const held = this.bestFace.get(trackId);
5053
5248
  this.aggregates.delete(trackId);
5054
5249
  this.bestFace.delete(trackId);
5250
+ this.bestTracker.delete(trackId);
5055
5251
  if (held === void 0) {
5056
5252
  this.deps.logger.debug("face: track ended without a held face", { tags: {
5057
5253
  deviceId,
@@ -5727,6 +5923,12 @@ function createEventMediaHandler(deps) {
5727
5923
  */
5728
5924
  var TTL_SWEEP_INTERVAL_MS = 5e3;
5729
5925
  var SETTINGS_CACHE_TTL_MS = 5e3;
5926
+ /** §5 best-frame: a track's `thumbnail` is overwritten only when the current
5927
+ * detection confidence beats the held best by at least this margin (hysteresis
5928
+ * so jitter around a plateau doesn't churn the write). */
5929
+ var BEST_FRAME_HYSTERESIS = .05;
5930
+ /** §5 best-frame: at most one best-thumbnail capture per this interval per track. */
5931
+ var BEST_FRAME_MIN_GAP_MS = 2e3;
5730
5932
  /** Cluster setting key (in the centralized addon store) selecting the SINGLE
5731
5933
  * node that runs post-analysis (event/media/audio/motion generation). All
5732
5934
  * other nodes are fully inert. No multi-node balancing. Default: the hub. */
@@ -5838,6 +6040,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
5838
6040
  mediaCacheByDevice = /* @__PURE__ */ new Map();
5839
6041
  /** Consecutive detector-dropout frames skipped per (deviceId, source) key. */
5840
6042
  dropoutSkipsByKey = /* @__PURE__ */ new Map();
6043
+ /** Best (highest-confidence) frame per track — drives the single overwrite
6044
+ * `thumbnail` (§5 best-frame). Shares the ONE best-detection policy with the
6045
+ * face path (`face-recognizer.ts`); rate-limited here since each best-frame
6046
+ * capture re-encodes a full boxed frame. */
6047
+ bestFrameTracker = new BestDetectionTracker({
6048
+ hysteresis: BEST_FRAME_HYSTERESIS,
6049
+ minGapMs: BEST_FRAME_MIN_GAP_MS
6050
+ });
5841
6051
  shuttingDown = false;
5842
6052
  /** True only on the cluster's designated post-processing node. When false the
5843
6053
  * addon subscribes to NOTHING — fully inert (no event/media generation). */
@@ -5871,7 +6081,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
5871
6081
  let storage = this.ctx.kernel.storage;
5872
6082
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
5873
6083
  if (mediaRoot) {
5874
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-Cx0LM0Sq.js"));
6084
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-CIEkEv1F.js"));
5875
6085
  storage = new FilesystemStorageProvider(mediaRoot);
5876
6086
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
5877
6087
  }
@@ -6381,6 +6591,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6381
6591
  this.processors.clear();
6382
6592
  this.lastActiveTrackIds.clear();
6383
6593
  this.dropoutSkipsByKey.clear();
6594
+ this.bestFrameTracker.clear();
6384
6595
  this.levelStateByDevice.clear();
6385
6596
  this.settingsCacheByDevice.clear();
6386
6597
  this.sensitivityCacheByDevice.clear();
@@ -6487,12 +6698,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6487
6698
  className: t.className,
6488
6699
  source
6489
6700
  } });
6490
- if (this.eventMediaDispatcher && frameHandle) firstFrameTargets.push({
6491
- trackId: id,
6492
- timestamp: result.timestamp,
6493
- bbox: { ...t.bbox },
6494
- ...t.label ? { label: t.label } : {}
6495
- });
6701
+ if (this.eventMediaDispatcher && frameHandle) {
6702
+ firstFrameTargets.push({
6703
+ trackId: id,
6704
+ timestamp: result.timestamp,
6705
+ bbox: { ...t.bbox },
6706
+ ...t.label ? { label: t.label } : {}
6707
+ });
6708
+ this.trackStore.seedSnapshotClock(id, result.timestamp);
6709
+ }
6496
6710
  this.ctx.eventBus.emit({
6497
6711
  id: `pa-${(0, node_crypto.randomUUID)()}`,
6498
6712
  timestamp: new Date(result.timestamp),
@@ -6565,11 +6779,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6565
6779
  let plateCrops = 0;
6566
6780
  for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
6567
6781
  else plateCrops += 1;
6568
- if (eventTargets.length > 0 || firstFrameTargets.length > 0) {
6782
+ const snapshotTargets = this.buildSnapshotTargets(result.tracked, result.timestamp, mediaSettings);
6783
+ if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
6569
6784
  log.info("media capture", { meta: {
6570
6785
  source,
6571
6786
  events: eventTargets.length,
6572
6787
  trackFrames: firstFrameTargets.length,
6788
+ snapshots: snapshotTargets.length,
6573
6789
  faceCrops,
6574
6790
  plateCrops
6575
6791
  } });
@@ -6578,8 +6794,20 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6578
6794
  frameHandle,
6579
6795
  events: eventTargets,
6580
6796
  trackFrames: firstFrameTargets,
6797
+ snapshots: snapshotTargets,
6581
6798
  cropPadding: mediaSettings.cropPadding
6582
- });
6799
+ }).then((res) => {
6800
+ for (const s of res.storedSnapshots) this.trackStore?.addSnapshot(s.trackId, {
6801
+ timestamp: s.timestamp,
6802
+ position: {
6803
+ x: s.bbox.x + s.bbox.w / 2,
6804
+ y: s.bbox.y + s.bbox.h / 2,
6805
+ timestamp: s.timestamp,
6806
+ bbox: s.bbox
6807
+ },
6808
+ mediaKey: s.mediaKey
6809
+ });
6810
+ }).catch(() => {});
6583
6811
  }
6584
6812
  }
6585
6813
  if (this.faceRecognizer && faceGloballyEnabled && faceSettings) this.faceRecognizer.processFrame({
@@ -6712,6 +6940,35 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6712
6940
  });
6713
6941
  return settings;
6714
6942
  }
6943
+ /**
6944
+ * §5 — decide which active tracks need periodic media THIS frame. Pure over
6945
+ * TrackStore.lastSnapshotAt + the per-track best-confidence map:
6946
+ * • `snapshot` (append) + `lastFrame` (rolling overwrite) fire together on
6947
+ * the snapshotIntervalMs cadence, gated by `saveThumbnails`;
6948
+ * • `thumbnail` (best) fires when confidence beats the held best by the
6949
+ * hysteresis margin, rate-limited to one per BEST_FRAME_MIN_GAP_MS, and is
6950
+ * NOT gated by saveThumbnails (a single best still-frame is always useful).
6951
+ * The per-track best-confidence map is updated here as a side effect.
6952
+ */
6953
+ buildSnapshotTargets(tracked, timestamp, media) {
6954
+ const targets = [];
6955
+ for (const t of tracked) {
6956
+ const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
6957
+ const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
6958
+ const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
6959
+ if (!dueSnapshot && !isNewBest) continue;
6960
+ targets.push({
6961
+ trackId: t.trackId,
6962
+ timestamp,
6963
+ bbox: { ...t.bbox },
6964
+ ...t.label ? { label: t.label } : {},
6965
+ appendSnapshot: dueSnapshot,
6966
+ rollingLastFrame: dueSnapshot,
6967
+ bestThumbnail: isNewBest
6968
+ });
6969
+ }
6970
+ return targets;
6971
+ }
6715
6972
  async handleAudioResult(data) {
6716
6973
  if (this.shuttingDown) return;
6717
6974
  const { deviceId, frame } = data;
@@ -6966,6 +7223,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
6966
7223
  });
6967
7224
  this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
6968
7225
  this.plateRecognizer?.onTrackEnd(t.deviceId, t.trackId);
7226
+ this.bestFrameTracker.delete(t.trackId);
6969
7227
  this.ctx.eventBus.emit({
6970
7228
  id: `pa-end-${t.trackId}`,
6971
7229
  timestamp: new Date(t.lastSeen),
@@ -7399,7 +7657,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
7399
7657
  min: 500,
7400
7658
  max: 6e4,
7401
7659
  step: 500,
7402
- default: 2e3,
7660
+ default: 5e3,
7403
7661
  showValue: true,
7404
7662
  unit: "s",
7405
7663
  displayScale: 1e3
@@ -1,4 +1,4 @@
1
- import { C as object, S as number, c as pipelineAnalyticsCapability, d as zoneAnalyticsCapability, f as errMsg, h as EventCategory, i as cosineSimilarity, l as plateGalleryCapability, m as DeviceType, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as BaseAddon, r as audioMetricsCapability, t as EVENT_PAD_MS, u as videoclipsCapability, v as hydrateSchema, w as string, x as boolean } from "../dist-DRK78NMq.mjs";
1
+ import { C as object, S as number, c as pipelineAnalyticsCapability, d as zoneAnalyticsCapability, f as errMsg, h as EventCategory, i as cosineSimilarity, l as plateGalleryCapability, m as DeviceType, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as BaseAddon, r as audioMetricsCapability, t as EVENT_PAD_MS, u as videoclipsCapability, v as hydrateSchema, w as string, x as boolean } from "../dist-Csk_yJr_.mjs";
2
2
  import { n as extractCrop, t as resolveFrame } from "../resolve-frame-CT1T1tWy.mjs";
3
3
  import { FrameRingReaderCache } from "@camstack/shm-ring";
4
4
  import sharp from "sharp";
@@ -1527,6 +1527,43 @@ var FrameProcessor = class {
1527
1527
  }
1528
1528
  };
1529
1529
  //#endregion
1530
+ //#region src/pipeline-analytics/pipeline/best-detection-tracker.ts
1531
+ var BestDetectionTracker = class {
1532
+ hysteresis;
1533
+ minGapMs;
1534
+ best = /* @__PURE__ */ new Map();
1535
+ constructor(options = {}) {
1536
+ this.hysteresis = options.hysteresis ?? 0;
1537
+ this.minGapMs = options.minGapMs ?? 0;
1538
+ }
1539
+ /**
1540
+ * Record a detection's `confidence` (at wall-clock `timestamp`) for `trackId`.
1541
+ * Returns true when it becomes the track's new best — the first sighting, or a
1542
+ * confidence that beats the held peak by more than `hysteresis` AND respects
1543
+ * `minGapMs`. On acceptance the held peak is advanced to this observation.
1544
+ */
1545
+ observe(trackId, confidence, timestamp) {
1546
+ const cur = this.best.get(trackId);
1547
+ const isNewBest = cur === void 0 || confidence > cur.confidence + this.hysteresis && timestamp - cur.atMs >= this.minGapMs;
1548
+ if (isNewBest) this.best.set(trackId, {
1549
+ confidence,
1550
+ atMs: timestamp
1551
+ });
1552
+ return isNewBest;
1553
+ }
1554
+ /** The held peak for a track (undefined if never observed). */
1555
+ peak(trackId) {
1556
+ return this.best.get(trackId);
1557
+ }
1558
+ /** Drop a track's peak (call at track end). */
1559
+ delete(trackId) {
1560
+ this.best.delete(trackId);
1561
+ }
1562
+ clear() {
1563
+ this.best.clear();
1564
+ }
1565
+ };
1566
+ //#endregion
1530
1567
  //#region src/pipeline-analytics/pipeline/native-detection.ts
1531
1568
  /**
1532
1569
  * Nominal frame size used to denormalize native `[0,1]` boxes when a
@@ -1796,6 +1833,16 @@ var TrackStore = class {
1796
1833
  lastSnapshotAt(trackId) {
1797
1834
  return this.active.get(trackId)?.lastSnapshotAt ?? 0;
1798
1835
  }
1836
+ /**
1837
+ * Seed the snapshot cadence clock (once, at track start) WITHOUT appending a
1838
+ * snapshot, so the first periodic snapshot fires ~snapshotIntervalMs after the
1839
+ * track begins rather than immediately — the `firstFrame` already covers the
1840
+ * track's start. No-op if a snapshot was already taken (clock already set).
1841
+ */
1842
+ seedSnapshotClock(trackId, timestamp) {
1843
+ const t = this.active.get(trackId);
1844
+ if (t && t.lastSnapshotAt === 0) t.lastSnapshotAt = timestamp;
1845
+ }
1799
1846
  getActive(deviceId) {
1800
1847
  const out = [];
1801
1848
  for (const t of this.active.values()) if (t.deviceId === deviceId && t.active) out.push(cloneTrack(t));
@@ -2060,6 +2107,46 @@ var MediaStore = class {
2060
2107
  }
2061
2108
  }
2062
2109
  /**
2110
+ * Overwrite semantics: keep exactly ONE media entry per (ownerKind, ownerId,
2111
+ * kind). Used for the rolling `lastFrame` and the best `thumbnail` per track —
2112
+ * each new capture replaces the previous one (blob + index row) rather than
2113
+ * accumulating a filmstrip the way `put` does. Deletes any existing rows of
2114
+ * that (owner, kind) first, then writes the fresh one. Returns the new key.
2115
+ */
2116
+ async putReplacing(params) {
2117
+ const existing = await this.store.query.query({
2118
+ collection: MEDIA_COLLECTION,
2119
+ filter: { where: {
2120
+ ownerKind: params.ownerKind,
2121
+ ownerId: params.ownerId,
2122
+ kind: params.kind
2123
+ } }
2124
+ });
2125
+ const newKey = await this.put(params);
2126
+ for (const row of existing) {
2127
+ if (row.id === newKey) continue;
2128
+ const path = String(row.data["path"] ?? "");
2129
+ if (path) try {
2130
+ await this.storage.delete({
2131
+ location: "eventMedia",
2132
+ relativePath: path
2133
+ });
2134
+ } catch {}
2135
+ try {
2136
+ await this.store.delete.mutate({
2137
+ collection: MEDIA_COLLECTION,
2138
+ key: row.id
2139
+ });
2140
+ } catch (err) {
2141
+ this.logger.debug("media putReplacing: stale row delete failed", { meta: {
2142
+ key: row.id,
2143
+ error: String(err)
2144
+ } });
2145
+ }
2146
+ }
2147
+ return newKey;
2148
+ }
2149
+ /**
2063
2150
  * Fetch one media entry by its key (id). Returns null if the key is not
2064
2151
  * found in the index or if the blob is missing from storage.
2065
2152
  */
@@ -2972,7 +3059,9 @@ var EventMediaDispatcher = class {
2972
3059
  }
2973
3060
  async captureForFrame(input) {
2974
3061
  const { deviceId, frameHandle, events, trackFrames } = input;
2975
- if (events.length === 0 && trackFrames.length === 0) return;
3062
+ const snapshots = input.snapshots ?? [];
3063
+ const empty = { storedSnapshots: [] };
3064
+ if (events.length === 0 && trackFrames.length === 0 && snapshots.length === 0) return empty;
2976
3065
  let decoded;
2977
3066
  try {
2978
3067
  decoded = await resolveFrame(frameHandle, {
@@ -2989,7 +3078,7 @@ var EventMediaDispatcher = class {
2989
3078
  error: String(err)
2990
3079
  }
2991
3080
  });
2992
- return;
3081
+ return empty;
2993
3082
  }
2994
3083
  if (!decoded) {
2995
3084
  this.deps.logger.debug("event media: frame recycled before resolve", {
@@ -2999,7 +3088,7 @@ var EventMediaDispatcher = class {
2999
3088
  shmId: frameHandle.shmId
3000
3089
  }
3001
3090
  });
3002
- return;
3091
+ return empty;
3003
3092
  }
3004
3093
  if (decoded.format !== "rgb") {
3005
3094
  this.deps.logger.debug("event media: resolved frame is not RGB", {
@@ -3009,13 +3098,87 @@ var EventMediaDispatcher = class {
3009
3098
  format: decoded.format
3010
3099
  }
3011
3100
  });
3012
- return;
3101
+ return empty;
3013
3102
  }
3014
3103
  const frameData = Buffer.isBuffer(decoded.data) ? decoded.data : Buffer.from(decoded.data);
3015
3104
  const fw = decoded.width;
3016
3105
  const fh = decoded.height;
3017
3106
  for (const ev of events) await this.writeEventMedia(deviceId, frameData, fw, fh, ev, input.cropPadding);
3018
3107
  for (const tf of trackFrames) await this.writeTrackFrame(deviceId, frameData, fw, fh, tf);
3108
+ const storedSnapshots = [];
3109
+ for (const sn of snapshots) {
3110
+ const stored = await this.writeTrackSnapshot(deviceId, frameData, fw, fh, sn);
3111
+ if (stored) storedSnapshots.push(stored);
3112
+ }
3113
+ return { storedSnapshots };
3114
+ }
3115
+ /**
3116
+ * Periodic per-track media (§5). Encodes the boxed frame ONCE and fans it out
3117
+ * to whichever of the three destinations is requested: an appended `snapshot`
3118
+ * (timeline filmstrip), the rolling `lastFrame` (overwrite), and the best
3119
+ * `thumbnail` (overwrite). Returns the appended snapshot for TrackStore wiring
3120
+ * (null when `appendSnapshot` is false or the encode failed).
3121
+ */
3122
+ async writeTrackSnapshot(deviceId, frameData, fw, fh, sn) {
3123
+ if (!sn.appendSnapshot && !sn.rollingLastFrame && !sn.bestThumbnail) return null;
3124
+ let boxed;
3125
+ try {
3126
+ boxed = await drawBoxedFrame(frameData, fw, fh, [{
3127
+ ...sn.bbox,
3128
+ ...sn.label ? { label: sn.label } : {}
3129
+ }], { quality: MEDIA_QUALITY });
3130
+ } catch (err) {
3131
+ this.deps.logger.warn("event media: track snapshot encode failed", {
3132
+ tags: { deviceId },
3133
+ meta: {
3134
+ deviceId,
3135
+ trackId: sn.trackId,
3136
+ error: err instanceof Error ? err.message : String(err)
3137
+ }
3138
+ });
3139
+ return null;
3140
+ }
3141
+ let stored = null;
3142
+ if (sn.appendSnapshot) try {
3143
+ const mediaKey = await this.deps.mediaStore.put({
3144
+ deviceId,
3145
+ ownerKind: "track",
3146
+ ownerId: sn.trackId,
3147
+ kind: "snapshot",
3148
+ timestamp: sn.timestamp,
3149
+ data: boxed
3150
+ });
3151
+ stored = {
3152
+ trackId: sn.trackId,
3153
+ mediaKey,
3154
+ timestamp: sn.timestamp,
3155
+ bbox: sn.bbox
3156
+ };
3157
+ } catch {}
3158
+ if (sn.rollingLastFrame) await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
3159
+ if (sn.bestThumbnail) await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, boxed);
3160
+ return stored;
3161
+ }
3162
+ async replaceKind(deviceId, trackId, kind, timestamp, data) {
3163
+ try {
3164
+ await this.deps.mediaStore.putReplacing({
3165
+ deviceId,
3166
+ ownerKind: "track",
3167
+ ownerId: trackId,
3168
+ kind,
3169
+ timestamp,
3170
+ data
3171
+ });
3172
+ } catch (err) {
3173
+ this.deps.logger.debug(`event media: ${kind} replace failed`, {
3174
+ tags: { deviceId },
3175
+ meta: {
3176
+ deviceId,
3177
+ trackId,
3178
+ error: err instanceof Error ? err.message : String(err)
3179
+ }
3180
+ });
3181
+ }
3019
3182
  }
3020
3183
  async writeEventMedia(deviceId, frameData, fw, fh, ev, cropPadding) {
3021
3184
  const box = {
@@ -4010,11 +4173,20 @@ function resolveFaceSettings(raw) {
4010
4173
  * its default — parse never throws). Mirrors `face-settings` /
4011
4174
  * `audio-detection-settings`.
4012
4175
  */
4013
- var MediaSettingsSchema = object({
4014
- /** Fractional padding added around a detection bbox before cropping.
4015
- * 0 = tight crop, 0.15 = expand each side by 15% of the box dimension,
4016
- * max 2 (200%). */
4017
- cropPadding: number().min(0).max(2).default(.15) });
4176
+ var MediaSettingsSchema = object({
4177
+ /** Fractional padding added around a detection bbox before cropping.
4178
+ * 0 = tight crop, 0.15 = expand each side by 15% of the box dimension,
4179
+ * max 2 (200%). */
4180
+ cropPadding: number().min(0).max(2).default(.15),
4181
+ /** Master switch for periodic per-track snapshots (the timeline filmstrip +
4182
+ * the rolling `lastFrame` + the best `thumbnail`). When false, only the
4183
+ * per-track `firstFrame` and per-event media are produced. */
4184
+ saveThumbnails: boolean().default(true),
4185
+ /** Cadence (ms) for the periodic per-track `snapshot` + rolling `lastFrame`.
4186
+ * A snapshot is captured for an active track only after this much wall-clock
4187
+ * has elapsed since its previous one. */
4188
+ snapshotIntervalMs: number().int().min(500).max(6e4).default(5e3)
4189
+ });
4018
4190
  var MEDIA_DEFAULTS = MediaSettingsSchema.parse({});
4019
4191
  /**
4020
4192
  * Resolve a per-device store blob into typed media settings. Unknown/invalid
@@ -4025,7 +4197,11 @@ function resolveMediaSettings(raw) {
4025
4197
  const parsed = MediaSettingsSchema.shape[key].safeParse(raw[key]);
4026
4198
  return parsed.success ? parsed.data : MEDIA_DEFAULTS[key];
4027
4199
  };
4028
- return { cropPadding: pick("cropPadding") };
4200
+ return {
4201
+ cropPadding: pick("cropPadding"),
4202
+ saveThumbnails: pick("saveThumbnails"),
4203
+ snapshotIntervalMs: pick("snapshotIntervalMs")
4204
+ };
4029
4205
  }
4030
4206
  //#endregion
4031
4207
  //#region src/pipeline-analytics/store/identity-store.ts
@@ -4921,6 +5097,11 @@ var FaceRecognizer = class {
4921
5097
  names = /* @__PURE__ */ new Map();
4922
5098
  aggregates = /* @__PURE__ */ new Map();
4923
5099
  bestFace = /* @__PURE__ */ new Map();
5100
+ /** The ONE best-detection-per-track policy, shared with the best-frame path
5101
+ * (`index.ts`). Face params: no hysteresis / no rate-limit — always keep the
5102
+ * true highest-confidence face (holding a buffer in memory is cheap, and a
5103
+ * track may last well under the best-frame rate-limit window). */
5104
+ bestTracker = new BestDetectionTracker();
4924
5105
  constructor(deps) {
4925
5106
  this.deps = deps;
4926
5107
  }
@@ -4954,55 +5135,25 @@ var FaceRecognizer = class {
4954
5135
  threshold: settings.similarityThreshold,
4955
5136
  margin: settings.margin
4956
5137
  }) : /* @__PURE__ */ new Map();
5138
+ const labelWork = [];
4957
5139
  for (const c of candidates) {
4958
5140
  const match = matches.get(c.trackId) ?? null;
4959
5141
  const { state, changed } = updateTrackAggregate(this.aggregates.get(c.trackId) ?? EMPTY_AGGREGATE, match, { confirmFrames: settings.confirmFrames });
4960
5142
  this.aggregates.set(c.trackId, state);
4961
- if (changed && state.assignedIdentityId !== null) {
4962
- const name = this.names.get(state.assignedIdentityId);
4963
- this.deps.logger.info("face: identity assigned", {
4964
- tags: {
4965
- deviceId: input.deviceId,
4966
- trackId: c.trackId
4967
- },
4968
- meta: {
4969
- identityId: state.assignedIdentityId,
4970
- name: name ?? null,
4971
- score: match?.score ?? null
4972
- }
4973
- });
4974
- if (name !== void 0) {
4975
- try {
4976
- await this.deps.trackStore.setLabel(c.trackId, name);
4977
- } catch (err) {
4978
- this.deps.logger.warn("setLabel failed", {
4979
- tags: { deviceId: input.deviceId },
4980
- meta: {
4981
- trackId: c.trackId,
4982
- error: String(err)
4983
- }
4984
- });
4985
- }
4986
- try {
4987
- await this.deps.eventStore.setLabelForTrack(c.trackId, name);
4988
- } catch (err) {
4989
- this.deps.logger.warn("setLabelForTrack failed", {
4990
- tags: { deviceId: input.deviceId },
4991
- meta: {
4992
- trackId: c.trackId,
4993
- error: String(err)
4994
- }
4995
- });
4996
- }
4997
- }
4998
- }
5143
+ if (changed && state.assignedIdentityId !== null) labelWork.push({
5144
+ trackId: c.trackId,
5145
+ assignedIdentityId: state.assignedIdentityId,
5146
+ matchScore: match?.score ?? null
5147
+ });
4999
5148
  const recognizedIdentityId = state.assignedIdentityId ?? match?.identityId ?? void 0;
5000
5149
  const held = this.bestFace.get(c.trackId);
5001
- if (held === void 0 || c.confidence > held.score) {
5150
+ const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp);
5151
+ const needsCrop = held !== void 0 && held.crop === void 0;
5152
+ if (isNewBest || needsCrop) {
5002
5153
  const cropBbox = c.faceBbox ?? c.bbox;
5003
5154
  let crop;
5004
- if (input.frameHandle !== void 0) try {
5005
- crop = await this.deps.captureCrop(input.frameHandle, cropBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
5155
+ if (c.faceBbox !== void 0 && input.frameHandle !== void 0) try {
5156
+ crop = await this.deps.captureCrop(input.frameHandle, c.faceBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
5006
5157
  } catch (err) {
5007
5158
  this.deps.logger.debug("FaceRecognizer crop capture failed", {
5008
5159
  tags: { deviceId: input.deviceId },
@@ -5012,14 +5163,20 @@ var FaceRecognizer = class {
5012
5163
  }
5013
5164
  });
5014
5165
  }
5015
- this.bestFace.set(c.trackId, {
5016
- score: c.confidence,
5017
- embedding: c.embedding,
5018
- embeddingModelId: c.embeddingModelId,
5019
- bbox: cropBbox,
5020
- timestamp: input.timestamp,
5021
- ...crop !== void 0 ? { crop } : {},
5022
- ...recognizedIdentityId !== void 0 ? { recognizedIdentityId } : {}
5166
+ if (isNewBest) {
5167
+ const bestCrop = crop ?? held?.crop;
5168
+ this.bestFace.set(c.trackId, {
5169
+ score: c.confidence,
5170
+ embedding: c.embedding,
5171
+ embeddingModelId: c.embeddingModelId,
5172
+ bbox: cropBbox,
5173
+ timestamp: input.timestamp,
5174
+ ...bestCrop !== void 0 ? { crop: bestCrop } : {},
5175
+ ...recognizedIdentityId !== void 0 ? { recognizedIdentityId } : {}
5176
+ });
5177
+ } else if (crop !== void 0 && held !== void 0) this.bestFace.set(c.trackId, {
5178
+ ...held,
5179
+ crop
5023
5180
  });
5024
5181
  this.deps.logger.debug("face: best-face held", {
5025
5182
  tags: {
@@ -5028,15 +5185,53 @@ var FaceRecognizer = class {
5028
5185
  },
5029
5186
  meta: {
5030
5187
  score: c.confidence,
5031
- hasCrop: crop !== void 0,
5188
+ isNewBest,
5189
+ hasCrop: this.bestFace.get(c.trackId)?.crop !== void 0,
5032
5190
  recognizedIdentityId: recognizedIdentityId ?? null
5033
5191
  }
5034
5192
  });
5035
- } else if (recognizedIdentityId !== void 0 && held.recognizedIdentityId === void 0) this.bestFace.set(c.trackId, {
5193
+ } else if (held !== void 0 && recognizedIdentityId !== void 0 && held.recognizedIdentityId === void 0) this.bestFace.set(c.trackId, {
5036
5194
  ...held,
5037
5195
  recognizedIdentityId
5038
5196
  });
5039
5197
  }
5198
+ for (const work of labelWork) {
5199
+ const name = this.names.get(work.assignedIdentityId);
5200
+ this.deps.logger.info("face: identity assigned", {
5201
+ tags: {
5202
+ deviceId: input.deviceId,
5203
+ trackId: work.trackId
5204
+ },
5205
+ meta: {
5206
+ identityId: work.assignedIdentityId,
5207
+ name: name ?? null,
5208
+ score: work.matchScore
5209
+ }
5210
+ });
5211
+ if (name === void 0) continue;
5212
+ try {
5213
+ await this.deps.trackStore.setLabel(work.trackId, name);
5214
+ } catch (err) {
5215
+ this.deps.logger.warn("setLabel failed", {
5216
+ tags: { deviceId: input.deviceId },
5217
+ meta: {
5218
+ trackId: work.trackId,
5219
+ error: String(err)
5220
+ }
5221
+ });
5222
+ }
5223
+ try {
5224
+ await this.deps.eventStore.setLabelForTrack(work.trackId, name);
5225
+ } catch (err) {
5226
+ this.deps.logger.warn("setLabelForTrack failed", {
5227
+ tags: { deviceId: input.deviceId },
5228
+ meta: {
5229
+ trackId: work.trackId,
5230
+ error: String(err)
5231
+ }
5232
+ });
5233
+ }
5234
+ }
5040
5235
  }
5041
5236
  /**
5042
5237
  * Persist the held best face for a finished track as ONE FaceStore buffer
@@ -5047,6 +5242,7 @@ var FaceRecognizer = class {
5047
5242
  const held = this.bestFace.get(trackId);
5048
5243
  this.aggregates.delete(trackId);
5049
5244
  this.bestFace.delete(trackId);
5245
+ this.bestTracker.delete(trackId);
5050
5246
  if (held === void 0) {
5051
5247
  this.deps.logger.debug("face: track ended without a held face", { tags: {
5052
5248
  deviceId,
@@ -5722,6 +5918,12 @@ function createEventMediaHandler(deps) {
5722
5918
  */
5723
5919
  var TTL_SWEEP_INTERVAL_MS = 5e3;
5724
5920
  var SETTINGS_CACHE_TTL_MS = 5e3;
5921
+ /** §5 best-frame: a track's `thumbnail` is overwritten only when the current
5922
+ * detection confidence beats the held best by at least this margin (hysteresis
5923
+ * so jitter around a plateau doesn't churn the write). */
5924
+ var BEST_FRAME_HYSTERESIS = .05;
5925
+ /** §5 best-frame: at most one best-thumbnail capture per this interval per track. */
5926
+ var BEST_FRAME_MIN_GAP_MS = 2e3;
5725
5927
  /** Cluster setting key (in the centralized addon store) selecting the SINGLE
5726
5928
  * node that runs post-analysis (event/media/audio/motion generation). All
5727
5929
  * other nodes are fully inert. No multi-node balancing. Default: the hub. */
@@ -5833,6 +6035,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
5833
6035
  mediaCacheByDevice = /* @__PURE__ */ new Map();
5834
6036
  /** Consecutive detector-dropout frames skipped per (deviceId, source) key. */
5835
6037
  dropoutSkipsByKey = /* @__PURE__ */ new Map();
6038
+ /** Best (highest-confidence) frame per track — drives the single overwrite
6039
+ * `thumbnail` (§5 best-frame). Shares the ONE best-detection policy with the
6040
+ * face path (`face-recognizer.ts`); rate-limited here since each best-frame
6041
+ * capture re-encodes a full boxed frame. */
6042
+ bestFrameTracker = new BestDetectionTracker({
6043
+ hysteresis: BEST_FRAME_HYSTERESIS,
6044
+ minGapMs: BEST_FRAME_MIN_GAP_MS
6045
+ });
5836
6046
  shuttingDown = false;
5837
6047
  /** True only on the cluster's designated post-processing node. When false the
5838
6048
  * addon subscribes to NOTHING — fully inert (no event/media generation). */
@@ -6376,6 +6586,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6376
6586
  this.processors.clear();
6377
6587
  this.lastActiveTrackIds.clear();
6378
6588
  this.dropoutSkipsByKey.clear();
6589
+ this.bestFrameTracker.clear();
6379
6590
  this.levelStateByDevice.clear();
6380
6591
  this.settingsCacheByDevice.clear();
6381
6592
  this.sensitivityCacheByDevice.clear();
@@ -6482,12 +6693,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6482
6693
  className: t.className,
6483
6694
  source
6484
6695
  } });
6485
- if (this.eventMediaDispatcher && frameHandle) firstFrameTargets.push({
6486
- trackId: id,
6487
- timestamp: result.timestamp,
6488
- bbox: { ...t.bbox },
6489
- ...t.label ? { label: t.label } : {}
6490
- });
6696
+ if (this.eventMediaDispatcher && frameHandle) {
6697
+ firstFrameTargets.push({
6698
+ trackId: id,
6699
+ timestamp: result.timestamp,
6700
+ bbox: { ...t.bbox },
6701
+ ...t.label ? { label: t.label } : {}
6702
+ });
6703
+ this.trackStore.seedSnapshotClock(id, result.timestamp);
6704
+ }
6491
6705
  this.ctx.eventBus.emit({
6492
6706
  id: `pa-${randomUUID()}`,
6493
6707
  timestamp: new Date(result.timestamp),
@@ -6560,11 +6774,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6560
6774
  let plateCrops = 0;
6561
6775
  for (const crops of childCropsByEvent.values()) for (const c of crops) if (c.kind === "faceCrop") faceCrops += 1;
6562
6776
  else plateCrops += 1;
6563
- if (eventTargets.length > 0 || firstFrameTargets.length > 0) {
6777
+ const snapshotTargets = this.buildSnapshotTargets(result.tracked, result.timestamp, mediaSettings);
6778
+ if (eventTargets.length > 0 || firstFrameTargets.length > 0 || snapshotTargets.length > 0) {
6564
6779
  log.info("media capture", { meta: {
6565
6780
  source,
6566
6781
  events: eventTargets.length,
6567
6782
  trackFrames: firstFrameTargets.length,
6783
+ snapshots: snapshotTargets.length,
6568
6784
  faceCrops,
6569
6785
  plateCrops
6570
6786
  } });
@@ -6573,8 +6789,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6573
6789
  frameHandle,
6574
6790
  events: eventTargets,
6575
6791
  trackFrames: firstFrameTargets,
6792
+ snapshots: snapshotTargets,
6576
6793
  cropPadding: mediaSettings.cropPadding
6577
- });
6794
+ }).then((res) => {
6795
+ for (const s of res.storedSnapshots) this.trackStore?.addSnapshot(s.trackId, {
6796
+ timestamp: s.timestamp,
6797
+ position: {
6798
+ x: s.bbox.x + s.bbox.w / 2,
6799
+ y: s.bbox.y + s.bbox.h / 2,
6800
+ timestamp: s.timestamp,
6801
+ bbox: s.bbox
6802
+ },
6803
+ mediaKey: s.mediaKey
6804
+ });
6805
+ }).catch(() => {});
6578
6806
  }
6579
6807
  }
6580
6808
  if (this.faceRecognizer && faceGloballyEnabled && faceSettings) this.faceRecognizer.processFrame({
@@ -6707,6 +6935,35 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6707
6935
  });
6708
6936
  return settings;
6709
6937
  }
6938
+ /**
6939
+ * §5 — decide which active tracks need periodic media THIS frame. Pure over
6940
+ * TrackStore.lastSnapshotAt + the per-track best-confidence map:
6941
+ * • `snapshot` (append) + `lastFrame` (rolling overwrite) fire together on
6942
+ * the snapshotIntervalMs cadence, gated by `saveThumbnails`;
6943
+ * • `thumbnail` (best) fires when confidence beats the held best by the
6944
+ * hysteresis margin, rate-limited to one per BEST_FRAME_MIN_GAP_MS, and is
6945
+ * NOT gated by saveThumbnails (a single best still-frame is always useful).
6946
+ * The per-track best-confidence map is updated here as a side effect.
6947
+ */
6948
+ buildSnapshotTargets(tracked, timestamp, media) {
6949
+ const targets = [];
6950
+ for (const t of tracked) {
6951
+ const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
6952
+ const dueSnapshot = media.saveThumbnails && lastSnap > 0 && timestamp - lastSnap >= media.snapshotIntervalMs;
6953
+ const isNewBest = this.bestFrameTracker.observe(t.trackId, t.confidence, timestamp);
6954
+ if (!dueSnapshot && !isNewBest) continue;
6955
+ targets.push({
6956
+ trackId: t.trackId,
6957
+ timestamp,
6958
+ bbox: { ...t.bbox },
6959
+ ...t.label ? { label: t.label } : {},
6960
+ appendSnapshot: dueSnapshot,
6961
+ rollingLastFrame: dueSnapshot,
6962
+ bestThumbnail: isNewBest
6963
+ });
6964
+ }
6965
+ return targets;
6966
+ }
6710
6967
  async handleAudioResult(data) {
6711
6968
  if (this.shuttingDown) return;
6712
6969
  const { deviceId, frame } = data;
@@ -6961,6 +7218,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
6961
7218
  });
6962
7219
  this.faceRecognizer?.onTrackEnd(t.deviceId, t.trackId);
6963
7220
  this.plateRecognizer?.onTrackEnd(t.deviceId, t.trackId);
7221
+ this.bestFrameTracker.delete(t.trackId);
6964
7222
  this.ctx.eventBus.emit({
6965
7223
  id: `pa-end-${t.trackId}`,
6966
7224
  timestamp: new Date(t.lastSeen),
@@ -7394,7 +7652,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
7394
7652
  min: 500,
7395
7653
  max: 6e4,
7396
7654
  step: 500,
7397
- default: 2e3,
7655
+ default: 5e3,
7398
7656
  showValue: true,
7399
7657
  unit: "s",
7400
7658
  displayScale: 1e3
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BsRL9Bh-.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Cb6Z2H5K.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-B0VgBdBN.js");
1
+ const require_dist = require("./dist-CCC79h7t.js");
2
2
  let sharp = require("sharp");
3
3
  sharp = require_dist.__toESM(sharp);
4
4
  //#region src/shared/frame/crop-extractor.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.1.24",
3
+ "version": "1.1.25",
4
4
  "description": "CamStack Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",