@camstack/addon-post-analysis 1.2.38 → 1.2.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DQvvZ5_E.js");
5
+ const require_dist = require("../dist-C09kySqI.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
8
8
  node_path = require_dist.__toESM(node_path);
@@ -2255,8 +2255,8 @@ var NcDispatcher = class {
2255
2255
  try {
2256
2256
  const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
2257
2257
  if (points.length === 0) return null;
2258
- const { default: sharp$7 } = await import("sharp");
2259
- const img = sharp$7(Buffer.from(jpeg));
2258
+ const { default: sharp$8 } = await import("sharp");
2259
+ const img = sharp$8(Buffer.from(jpeg));
2260
2260
  const meta = await img.metadata();
2261
2261
  const W = meta.width ?? 0;
2262
2262
  const H = meta.height ?? 0;
@@ -11955,6 +11955,189 @@ var EmbeddingDispatcher = class {
11955
11955
  }
11956
11956
  };
11957
11957
  //#endregion
11958
+ //#region src/pipeline-analytics/pipeline/bbox-pad.ts
11959
+ /**
11960
+ * Pad a normalized bounding box by a fractional amount relative to its own
11961
+ * dimensions. All coordinates remain in the [0, 1] unit-square.
11962
+ *
11963
+ * Formula (before clamping):
11964
+ * newX = x - padding * w
11965
+ * newW = w + 2 * padding * w (i.e. w * (1 + 2 * padding))
11966
+ * newY = y - padding * h
11967
+ * newH = h + 2 * padding * h
11968
+ *
11969
+ * padding === 0 is the identity operation. Pure, immutable — always returns a
11970
+ * new object.
11971
+ *
11972
+ * NOTE: clamping SHIFTS, it does not truncate — a window that overflows a
11973
+ * bound slides inward and keeps the extent that was asked for, on either side.
11974
+ * It shrinks only when the padded window is larger than the frame itself
11975
+ * would (e.g. a left-hugging box gains a bit more right-side context). This is
11976
+ * acceptable for detection crops (more context, never out of [0,1]); the
11977
+ * downstream `extractCrop` clamps to pixel bounds again regardless.
11978
+ */
11979
+ function padBbox(bbox, padding) {
11980
+ const rawX = bbox.x - padding * bbox.w;
11981
+ const rawY = bbox.y - padding * bbox.h;
11982
+ const rawW = bbox.w * (1 + 2 * padding);
11983
+ const rawH = bbox.h * (1 + 2 * padding);
11984
+ const w = Math.min(rawW, 1);
11985
+ const h = Math.min(rawH, 1);
11986
+ return {
11987
+ x: Math.min(Math.max(0, rawX), 1 - w),
11988
+ y: Math.min(Math.max(0, rawY), 1 - h),
11989
+ w,
11990
+ h
11991
+ };
11992
+ }
11993
+ /**
11994
+ * Widen a bbox and optionally square it, staying inside the unit frame.
11995
+ *
11996
+ * Squaring uses the frame's aspect so the window is square in PIXELS, not in
11997
+ * normalised units — those are only the same on a square frame, and no camera
11998
+ * produces one.
11999
+ */
12000
+ function cropWindow(bbox, frameWidth, frameHeight, options) {
12001
+ const padded = padBbox(bbox, options.margin);
12002
+ if (!options.square) return padded;
12003
+ const pxW = padded.w * frameWidth;
12004
+ const pxH = padded.h * frameHeight;
12005
+ const side = Math.max(pxW, pxH);
12006
+ const w = Math.min(side / frameWidth, 1);
12007
+ const h = Math.min(side / frameHeight, 1);
12008
+ const cx = padded.x + padded.w / 2;
12009
+ const cy = padded.y + padded.h / 2;
12010
+ return {
12011
+ w,
12012
+ h,
12013
+ x: Math.min(Math.max(0, cx - w / 2), 1 - w),
12014
+ y: Math.min(Math.max(0, cy - h / 2), 1 - h)
12015
+ };
12016
+ }
12017
+ /** Cut a JPEG crop out of a JPEG frame using a normalised window. */
12018
+ async function cropFromJpeg(jpeg, window) {
12019
+ const image = (0, sharp.default)(jpeg);
12020
+ const meta = await image.metadata();
12021
+ const frameWidth = meta.width;
12022
+ const frameHeight = meta.height;
12023
+ if (frameWidth === void 0 || frameHeight === void 0) return null;
12024
+ const left = Math.max(0, Math.min(Math.round(window.x * frameWidth), frameWidth - 1));
12025
+ const top = Math.max(0, Math.min(Math.round(window.y * frameHeight), frameHeight - 1));
12026
+ const width = Math.max(1, Math.min(Math.round(window.w * frameWidth), frameWidth - left));
12027
+ const height = Math.max(1, Math.min(Math.round(window.h * frameHeight), frameHeight - top));
12028
+ return {
12029
+ crop: await image.extract({
12030
+ left,
12031
+ top,
12032
+ width,
12033
+ height
12034
+ }).jpeg({ quality: 90 }).toBuffer(),
12035
+ width,
12036
+ height
12037
+ };
12038
+ }
12039
+ /**
12040
+ * Re-embed every listed track from its key frame.
12041
+ *
12042
+ * Every skip is COUNTED and the counts are returned, because the caller's next
12043
+ * decision — whether the index is now consistent enough to trust — depends on
12044
+ * knowing how much was not rebuilt, not just how much was.
12045
+ */
12046
+ async function rebuildObjectEmbeddings(deps, options = {}) {
12047
+ const margin = options.cropMargin ?? .2;
12048
+ const square = options.square ?? true;
12049
+ const maxTracks = options.maxTracks ?? Number.POSITIVE_INFINITY;
12050
+ const trackIds = await deps.listTrackIds();
12051
+ const considered = trackIds.slice(0, Number.isFinite(maxTracks) ? maxTracks : void 0);
12052
+ let rebuilt = 0;
12053
+ let missingKeyFrame = 0;
12054
+ let missingBbox = 0;
12055
+ let failed = 0;
12056
+ for (const trackId of considered) try {
12057
+ const source = await deps.loadSource(trackId);
12058
+ if (source === null) {
12059
+ missingKeyFrame += 1;
12060
+ continue;
12061
+ }
12062
+ if (source.keyFrame === null) {
12063
+ missingKeyFrame += 1;
12064
+ continue;
12065
+ }
12066
+ if (source.bbox === null || source.bbox.w <= 0 || source.bbox.h <= 0) {
12067
+ missingBbox += 1;
12068
+ continue;
12069
+ }
12070
+ const meta = await (0, sharp.default)(source.keyFrame).metadata();
12071
+ if (meta.width === void 0 || meta.height === void 0) {
12072
+ failed += 1;
12073
+ continue;
12074
+ }
12075
+ const window = cropWindow(source.bbox, meta.width, meta.height, {
12076
+ margin,
12077
+ square
12078
+ });
12079
+ const cut = await cropFromJpeg(source.keyFrame, window);
12080
+ if (cut === null) {
12081
+ failed += 1;
12082
+ continue;
12083
+ }
12084
+ const embedding = await deps.encode(cut.crop, cut.width, cut.height);
12085
+ if (embedding.length === 0) {
12086
+ failed += 1;
12087
+ continue;
12088
+ }
12089
+ await deps.store(source, embedding);
12090
+ rebuilt += 1;
12091
+ } catch (err) {
12092
+ failed += 1;
12093
+ deps.logger.warn("embedding rebuild failed for a track", { meta: {
12094
+ trackId,
12095
+ error: String(err)
12096
+ } });
12097
+ }
12098
+ const complete = considered.length === trackIds.length;
12099
+ deps.logger.info("object embedding rebuild finished", { meta: {
12100
+ scanned: considered.length,
12101
+ rebuilt,
12102
+ missingKeyFrame,
12103
+ missingBbox,
12104
+ failed,
12105
+ complete,
12106
+ cropMargin: margin,
12107
+ square
12108
+ } });
12109
+ return {
12110
+ scanned: considered.length,
12111
+ rebuilt,
12112
+ missingKeyFrame,
12113
+ missingBbox,
12114
+ failed,
12115
+ complete
12116
+ };
12117
+ }
12118
+ /**
12119
+ * Read a normalised bbox off an object event.
12120
+ *
12121
+ * Events carry the box in FRAME PIXELS alongside `frameWidth`/`frameHeight`,
12122
+ * not normalised — normalising here rather than at the call site keeps the
12123
+ * conversion in one place, next to the code that consumes it.
12124
+ * Returns null when the frame dimensions are missing, because a box without
12125
+ * them cannot be placed on a differently-sized key frame.
12126
+ */
12127
+ function normaliseEventBbox(event) {
12128
+ const { bbox, frameWidth, frameHeight } = event;
12129
+ if (bbox === void 0) return null;
12130
+ if (frameWidth === void 0 || frameHeight === void 0) return null;
12131
+ if (frameWidth <= 0 || frameHeight <= 0) return null;
12132
+ if (bbox.w <= 0 || bbox.h <= 0) return null;
12133
+ return {
12134
+ x: bbox.x / frameWidth,
12135
+ y: bbox.y / frameHeight,
12136
+ w: bbox.w / frameWidth,
12137
+ h: bbox.h / frameHeight
12138
+ };
12139
+ }
12140
+ //#endregion
11958
12141
  //#region src/pipeline-analytics/event-media-handler.ts
11959
12142
  var CACHE_CONTROL = "public, max-age=31536000, immutable";
11960
12143
  /** Default / clamp bounds for the `thumb` variant edge (px). Mirrors
@@ -14286,42 +14469,6 @@ async function encodeKeyFrameVariants(native) {
14286
14469
  };
14287
14470
  }
14288
14471
  //#endregion
14289
- //#region src/pipeline-analytics/pipeline/bbox-pad.ts
14290
- /**
14291
- * Pad a normalized bounding box by a fractional amount relative to its own
14292
- * dimensions. All coordinates remain in the [0, 1] unit-square.
14293
- *
14294
- * Formula (before clamping):
14295
- * newX = x - padding * w
14296
- * newW = w + 2 * padding * w (i.e. w * (1 + 2 * padding))
14297
- * newY = y - padding * h
14298
- * newH = h + 2 * padding * h
14299
- *
14300
- * padding === 0 is the identity operation. Pure, immutable — always returns a
14301
- * new object.
14302
- *
14303
- * NOTE: clamping SHIFTS, it does not truncate — a window that overflows a
14304
- * bound slides inward and keeps the extent that was asked for, on either side.
14305
- * It shrinks only when the padded window is larger than the frame itself
14306
- * would (e.g. a left-hugging box gains a bit more right-side context). This is
14307
- * acceptable for detection crops (more context, never out of [0,1]); the
14308
- * downstream `extractCrop` clamps to pixel bounds again regardless.
14309
- */
14310
- function padBbox(bbox, padding) {
14311
- const rawX = bbox.x - padding * bbox.w;
14312
- const rawY = bbox.y - padding * bbox.h;
14313
- const rawW = bbox.w * (1 + 2 * padding);
14314
- const rawH = bbox.h * (1 + 2 * padding);
14315
- const w = Math.min(rawW, 1);
14316
- const h = Math.min(rawH, 1);
14317
- return {
14318
- x: Math.min(Math.max(0, rawX), 1 - w),
14319
- y: Math.min(Math.max(0, rawY), 1 - h),
14320
- w,
14321
- h
14322
- };
14323
- }
14324
- //#endregion
14325
14472
  //#region src/pipeline-analytics/pipeline/keyframe-crop.ts
14326
14473
  function createKeyFrameCrop(deps) {
14327
14474
  return async (input) => {
@@ -20430,6 +20577,96 @@ var ObjectEmbeddingStore = class {
20430
20577
  }
20431
20578
  }
20432
20579
  /**
20580
+ * Retention sweep for ONE device.
20581
+ *
20582
+ * Replaces the fleet-global prune this store used to get. Events are evicted
20583
+ * per device at that device's own cutoff, but embeddings were pruned at the
20584
+ * MINIMUM cutoff across the fleet — "never prune anything a longer-retention
20585
+ * device still owns" — which left a short-retention camera with weeks of
20586
+ * vectors whose events were long gone. Measured on device 590: events back
20587
+ * 1.0 day, embeddings back 22.1, and 1,446 of 2,222 rows unrenderable.
20588
+ *
20589
+ * The fleet bound existed because the old store could not filter by device.
20590
+ * `deviceId` is a promoted, indexed column here, so this is one filter.
20591
+ */
20592
+ async pruneDeviceBefore(deviceId, cutoffMs) {
20593
+ try {
20594
+ return (await this.vectorStore.deleteByFilter.mutate({
20595
+ index: OBJECT_CLIP_INDEX,
20596
+ filter: {
20597
+ equals: { deviceId },
20598
+ between: { timestamp: [0, cutoffMs] }
20599
+ }
20600
+ })).deleted;
20601
+ } catch (err) {
20602
+ this.logger.warn("ObjectEmbeddingStore.pruneDeviceBefore failed", {
20603
+ tags: { deviceId },
20604
+ meta: {
20605
+ cutoffMs,
20606
+ error: String(err)
20607
+ }
20608
+ });
20609
+ return 0;
20610
+ }
20611
+ }
20612
+ /**
20613
+ * Drop every vector whose track no longer exists.
20614
+ *
20615
+ * The cascade already deletes a track's vector when the track is deleted, so
20616
+ * in principle this finds nothing. In practice it found 1,446 orphans on one
20617
+ * camera, because retention pruned by two different rules. A reconcile that
20618
+ * cannot explain how it got work to do is still the right shape — the
20619
+ * alternative is trusting that every deletion path remembered this store,
20620
+ * which is exactly the assumption that failed.
20621
+ *
20622
+ * `liveTrackIds` is the authority. An EMPTY set is refused rather than
20623
+ * treated as "delete everything": a track store that failed to answer would
20624
+ * otherwise wipe the whole index.
20625
+ */
20626
+ async pruneOrphans(input) {
20627
+ if (input.liveTrackIds.size === 0) {
20628
+ this.logger.warn("ObjectEmbeddingStore.pruneOrphans refused an empty live-track set", { meta: { candidates: input.candidates.length } });
20629
+ return {
20630
+ deleted: 0,
20631
+ refused: true
20632
+ };
20633
+ }
20634
+ const orphans = input.candidates.filter((id) => !input.liveTrackIds.has(id));
20635
+ if (orphans.length === 0) return {
20636
+ deleted: 0,
20637
+ refused: false
20638
+ };
20639
+ await this.deleteByTracks(orphans);
20640
+ this.logger.info("object embedding orphans pruned", { meta: {
20641
+ orphans: orphans.length,
20642
+ candidates: input.candidates.length
20643
+ } });
20644
+ return {
20645
+ deleted: orphans.length,
20646
+ refused: false
20647
+ };
20648
+ }
20649
+ /**
20650
+ * Empty the index completely — the clean slate before a policy change.
20651
+ *
20652
+ * Changing the crop margin or the encoder model leaves two feature spaces in
20653
+ * one index whose cosine scores are not comparable. Wiping first, then
20654
+ * rebuilding, is the only way to be sure every vector means the same thing.
20655
+ */
20656
+ async wipeAll() {
20657
+ try {
20658
+ const result = await this.vectorStore.deleteByFilter.mutate({
20659
+ index: OBJECT_CLIP_INDEX,
20660
+ filter: { between: { timestamp: [0, Number.MAX_SAFE_INTEGER] } }
20661
+ });
20662
+ this.logger.info("object embedding index wiped", { meta: { deleted: result.deleted } });
20663
+ return result.deleted;
20664
+ } catch (err) {
20665
+ this.logger.warn("ObjectEmbeddingStore.wipeAll failed", { meta: { error: String(err) } });
20666
+ return 0;
20667
+ }
20668
+ }
20669
+ /**
20433
20670
  * `TrackScopedStore` contract for the retention cascade: delete the per-track
20434
20671
  * CLIP search vector for the given tracks. Pure EPHEMERAL track state — the
20435
20672
  * identity MATCHING embeddings (ArcFace) live in a SEPARATE durable store
@@ -22693,6 +22930,15 @@ function buildStationarySnapshot(input) {
22693
22930
  //#region src/pipeline-analytics/index.ts
22694
22931
  /** This addon's manifest id — the owner of the fixed Notification Center
22695
22932
  * integration and of the alarm device under it. */
22933
+ /**
22934
+ * How long the active encoder's model id is trusted without re-asking.
22935
+ *
22936
+ * `embeddingEncoder.getInfo` measured 1.42s on the live hub — a cross-process
22937
+ * call into an addon busy with live inference — for a value that changes only
22938
+ * when an operator swaps encoders. A minute is short enough that a swap takes
22939
+ * effect without a restart and long enough that no search pays for it twice.
22940
+ */
22941
+ var ENCODER_INFO_TTL_MS = 6e4;
22696
22942
  var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
22697
22943
  /** Operator-visible name of the fixed integration. */
22698
22944
  var NC_INTEGRATION_NAME = "Notification Center";
@@ -22880,6 +23126,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22880
23126
  plateStore = null;
22881
23127
  plateRecognizer = null;
22882
23128
  objectEmbeddingStore = null;
23129
+ /** Cached active-encoder model id — see `resolveEncoderModelId`. */
23130
+ encoderInfoCache = null;
22883
23131
  /** Two-plane detail scheduler/dispatcher: per-track on-demand face/clip/
22884
23132
  * classifier/plate enrichment via `pipelineRunner.runDetailSubtree`. Replaces
22885
23133
  * the per-frame child consumption the executor no longer emits. */
@@ -23502,7 +23750,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
23502
23750
  let storage = this.ctx.kernel.storage;
23503
23751
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
23504
23752
  if (mediaRoot) {
23505
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-CJcK55RY.js"));
23753
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-DL2FM8_E.js"));
23506
23754
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
23507
23755
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
23508
23756
  }
@@ -25216,6 +25464,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
25216
25464
  });
25217
25465
  return;
25218
25466
  }
25467
+ const keyFrameMediaKey = this.residents.keyFrameKey(trackId);
25219
25468
  await store.upsertIfBetter({
25220
25469
  trackId,
25221
25470
  deviceId,
@@ -25223,7 +25472,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
25223
25472
  className: detail.className,
25224
25473
  embedding: decodeEmbeddingBase64(detail.embedding),
25225
25474
  modelId,
25226
- confidence: detail.score
25475
+ confidence: detail.score,
25476
+ ...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
25227
25477
  });
25228
25478
  }
25229
25479
  /** Classifier answer / plate OCR text → the track's enrichment label
@@ -25961,11 +26211,23 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
25961
26211
  } catch (err) {
25962
26212
  this.ctx.logger.debug("plate buffer prune failed", { meta: { error: String(err) } });
25963
26213
  }
25964
- if (this.objectEmbeddingStore && Number.isFinite(minObjectCutoffMs)) try {
25965
- const deletedEmbeddings = await this.objectEmbeddingStore.pruneAll(minObjectCutoffMs);
26214
+ if (this.objectEmbeddingStore) {
26215
+ const embeddingStore = this.objectEmbeddingStore;
26216
+ let deletedEmbeddings = 0;
26217
+ for (const deviceId of deviceIds) {
26218
+ if (this.shuttingDown) return;
26219
+ try {
26220
+ const cutoffs = await this.deviceRetentionCutoffs(deviceId, now);
26221
+ if (cutoffs.objectCutoffMs === null) continue;
26222
+ deletedEmbeddings += await embeddingStore.pruneDeviceBefore(deviceId, cutoffs.objectCutoffMs);
26223
+ } catch (err) {
26224
+ this.ctx.logger.debug("object embedding prune failed", {
26225
+ tags: { deviceId },
26226
+ meta: { error: String(err) }
26227
+ });
26228
+ }
26229
+ }
25966
26230
  if (deletedEmbeddings > 0) this.ctx.logger.info("object embedding retention prune", { meta: { deleted: deletedEmbeddings } });
25967
- } catch (err) {
25968
- this.ctx.logger.debug("object embedding prune failed", { meta: { error: String(err) } });
25969
26231
  }
25970
26232
  } catch (err) {
25971
26233
  this.ctx.logger.debug("sweepRetention failed", { meta: { error: String(err) } });
@@ -26511,6 +26773,129 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
26511
26773
  * with each winner's deviceId + trackId filter) and join score.
26512
26774
  * 5. Return at most `limit` ScoredObjectEvents sorted descending by score.
26513
26775
  */
26776
+ /**
26777
+ * The active encoder's model id, cached for {@link ENCODER_INFO_TTL_MS}.
26778
+ *
26779
+ * Refreshed rather than pinned forever so an operator swapping the encoder
26780
+ * does not need a restart to make search gate on the new model — the window
26781
+ * where the old id is used is bounded by the TTL, and during it a search
26782
+ * simply ranks against the previous model's vectors, which is what it would
26783
+ * have done anyway until the new ones exist.
26784
+ */
26785
+ async resolveEncoderModelId(api) {
26786
+ const now = Date.now();
26787
+ if (this.encoderInfoCache !== null && now - this.encoderInfoCache.at < ENCODER_INFO_TTL_MS) return this.encoderInfoCache.modelId;
26788
+ const info = await api.embeddingEncoder.getInfo.query().catch((err) => {
26789
+ this.ctx.logger.warn("searchObjectEvents: getInfo failed, proceeding without modelId gate", { meta: { error: String(err) } });
26790
+ return null;
26791
+ });
26792
+ if (info === null) return this.encoderInfoCache?.modelId ?? null;
26793
+ this.encoderInfoCache = {
26794
+ modelId: info.modelId,
26795
+ at: now
26796
+ };
26797
+ return info.modelId;
26798
+ }
26799
+ /**
26800
+ * Empty the CLIP index — the clean slate before a crop-policy or encoder
26801
+ * change, to be followed by {@link rebuildObjectEmbeddings}.
26802
+ */
26803
+ async wipeObjectEmbeddings() {
26804
+ const store = this.objectEmbeddingStore;
26805
+ if (!store) {
26806
+ this.ctx.logger.warn("wipeObjectEmbeddings: object embedding store unavailable");
26807
+ return { deleted: 0 };
26808
+ }
26809
+ return { deleted: await store.wipeAll() };
26810
+ }
26811
+ /**
26812
+ * Re-embed stored tracks from their key frames — the tool that makes the crop
26813
+ * policy tunable.
26814
+ *
26815
+ * Pixels come from each track's `keyFrame` (the FULL frame at native
26816
+ * resolution, captured at the best detection) plus that detection's bbox, so
26817
+ * the window can be WIDENED. The tight crop originally embedded cannot be
26818
+ * widened after the fact.
26819
+ */
26820
+ async rebuildObjectEmbeddings(input) {
26821
+ const store = this.objectEmbeddingStore;
26822
+ const trackStore = this.trackStore;
26823
+ const mediaStore = this.mediaStore;
26824
+ const eventStore = this.eventStore;
26825
+ const api = this.ctx.api;
26826
+ const empty = {
26827
+ scanned: 0,
26828
+ rebuilt: 0,
26829
+ missingKeyFrame: 0,
26830
+ missingBbox: 0,
26831
+ failed: 0,
26832
+ complete: true
26833
+ };
26834
+ if (!store || !trackStore || !mediaStore || !eventStore || !api) {
26835
+ this.ctx.logger.warn("rebuildObjectEmbeddings: analytics stores unavailable");
26836
+ return empty;
26837
+ }
26838
+ const modelId = await this.resolveEncoderModelId(api) ?? "unknown";
26839
+ const deviceIds = input.deviceId !== void 0 ? [input.deviceId] : await trackStore.listDeviceIds();
26840
+ return rebuildObjectEmbeddings({
26841
+ listTrackIds: async () => {
26842
+ return (await trackStore.queryRecent({
26843
+ deviceIds,
26844
+ ...input.since !== void 0 ? { since: input.since } : {},
26845
+ ...input.until !== void 0 ? { until: input.until } : {},
26846
+ limit: input.maxTracks ?? 500
26847
+ })).tracks.map((t) => t.trackId);
26848
+ },
26849
+ loadSource: async (trackId) => {
26850
+ const media = await mediaStore.listByOwner("track", trackId);
26851
+ const keyFrameFile = media.find((m) => m.kind === "keyFrame");
26852
+ const cropFile = media.find((m) => m.kind === "thumbnail");
26853
+ const bestEventId = (await eventStore.peakForTrack(trackId)).bestEventId;
26854
+ const events = await eventStore.queryObjectByTrackIds([trackId]);
26855
+ const best = events.find((e) => e.id === bestEventId) ?? events[0];
26856
+ if (best === void 0) return null;
26857
+ return {
26858
+ trackId,
26859
+ deviceId: best.deviceId,
26860
+ timestamp: best.timestamp,
26861
+ className: best.className,
26862
+ confidence: best.confidence ?? 0,
26863
+ keyFrame: keyFrameFile ? Buffer.from(keyFrameFile.base64, "base64") : null,
26864
+ bbox: normaliseEventBbox(best),
26865
+ ...cropFile !== void 0 ? { mediaKey: cropFile.key } : {},
26866
+ ...keyFrameFile !== void 0 ? { keyFrameMediaKey: keyFrameFile.key } : {}
26867
+ };
26868
+ },
26869
+ encode: async (crop, width, height) => {
26870
+ return (await api.embeddingEncoder.encode.query({
26871
+ crop: new Uint8Array(crop),
26872
+ width,
26873
+ height
26874
+ })).embedding;
26875
+ },
26876
+ store: async (source, embedding) => {
26877
+ await store.upsertIfBetter({
26878
+ trackId: source.trackId,
26879
+ deviceId: source.deviceId,
26880
+ timestamp: source.timestamp,
26881
+ className: source.className,
26882
+ embedding,
26883
+ modelId,
26884
+ confidence: Number.POSITIVE_INFINITY,
26885
+ ...source.mediaKey !== void 0 ? { mediaKey: source.mediaKey } : {},
26886
+ ...source.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: source.keyFrameMediaKey } : {}
26887
+ });
26888
+ },
26889
+ logger: {
26890
+ info: (message, extras) => this.ctx.logger.info(message, extras),
26891
+ warn: (message, extras) => this.ctx.logger.warn(message, extras)
26892
+ }
26893
+ }, {
26894
+ ...input.cropMargin !== void 0 ? { cropMargin: input.cropMargin } : {},
26895
+ ...input.square !== void 0 ? { square: input.square } : {},
26896
+ ...input.maxTracks !== void 0 ? { maxTracks: input.maxTracks } : {}
26897
+ });
26898
+ }
26514
26899
  async searchObjectEvents(input) {
26515
26900
  const store = this.objectEmbeddingStore;
26516
26901
  if (!store) return [];
@@ -26522,10 +26907,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
26522
26907
  });
26523
26908
  if (!encodeResult) return [];
26524
26909
  const queryVec = new Float32Array(encodeResult.embedding);
26525
- const encoderInfo = await api.embeddingEncoder.getInfo.query().catch((err) => {
26526
- this.ctx.logger.warn("searchObjectEvents: getInfo failed, proceeding without modelId gate", { meta: { error: String(err) } });
26527
- return null;
26528
- });
26910
+ const encoderModelId = await this.resolveEncoderModelId(api);
26529
26911
  const search = await store.searchByVector({
26530
26912
  vector: queryVec,
26531
26913
  limit: input.limit,
@@ -26534,7 +26916,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
26534
26916
  ...input.since !== void 0 ? { since: input.since } : {},
26535
26917
  ...input.until !== void 0 ? { until: input.until } : {},
26536
26918
  ...input.classFilter !== void 0 ? { className: input.classFilter } : {},
26537
- ...encoderInfo !== null ? { modelId: encoderInfo.modelId } : {}
26919
+ ...encoderModelId !== null ? { modelId: encoderModelId } : {}
26538
26920
  });
26539
26921
  if (search.truncated) this.ctx.logger.warn("searchObjectEvents: candidate pool truncated", {
26540
26922
  meta: {