@camstack/addon-post-analysis 1.2.46 → 1.2.47

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.
@@ -15508,14 +15508,6 @@ var RebuildObjectEmbeddingsInput = object({
15508
15508
  deviceId: number().optional(),
15509
15509
  since: number().optional(),
15510
15510
  until: number().optional(),
15511
- /**
15512
- * Fraction added on EACH side of the detection box before cropping.
15513
- * ~0.2 (a 1.4x window) gives CLIP the surroundings it is trained on; 0 is
15514
- * the pixel-tight crop the first implementation used.
15515
- */
15516
- cropMargin: number().min(0).max(4).optional(),
15517
- /** Square the window before extracting, so a tall subject is not squashed. */
15518
- square: boolean().optional(),
15519
15511
  /** Stop after this many tracks; the result reports whether more remain. */
15520
15512
  maxTracks: number().int().positive().optional()
15521
15513
  });
@@ -15550,6 +15542,14 @@ var RebuildStatusSchema = object({
15550
15542
  missingKeyFrame: number(),
15551
15543
  /** Tracks with no usable detection box. */
15552
15544
  missingBbox: number(),
15545
+ /**
15546
+ * Tracks the pipeline REFUSED rather than broke on: the camera is not
15547
+ * attached, or `clip-embedding` is not enabled in its step tree. Separate
15548
+ * from `failed` because the remedy is a configuration change, not an engine
15549
+ * investigation — and because a pass over decommissioned cameras would
15550
+ * otherwise read as a total engine outage.
15551
+ */
15552
+ notRunnable: number(),
15553
15553
  failed: number(),
15554
15554
  /** Set once a pass ends: true only when EVERYTHING was covered. */
15555
15555
  complete: boolean().nullable(),
@@ -16800,6 +16800,17 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
16800
16800
  }), NativeCropResultSchema.nullable()), method(object({
16801
16801
  deviceId: number(),
16802
16802
  frameHandle: FrameHandleSchema.optional(),
16803
+ /**
16804
+ * FULL FRAME (base64 JPEG). The runner derives the crop rectangle from
16805
+ * `parent.bbox` with the cluster crop convention and cuts it itself —
16806
+ * do NOT pre-crop for this field, that is what `cropJpeg` is.
16807
+ */
16808
+ frameJpeg: string().optional(),
16809
+ /**
16810
+ * PRE-CUT tile (base64 JPEG), used verbatim — NO padding is applied.
16811
+ * The fallback when the lease/session backing the frame is gone and the
16812
+ * caller already holds a crop.
16813
+ */
16803
16814
  cropJpeg: string().optional(),
16804
16815
  parent: DetailParentSchema,
16805
16816
  steps: array(string()).optional()
@@ -31391,6 +31402,34 @@ function encodeVectorBase64(vector) {
31391
31402
  for (let i = 0; i < floats.length; i += 1) view.setFloat32(i * 4, floats[i] ?? 0, true);
31392
31403
  return Buffer.from(bytes).toString("base64");
31393
31404
  }
31405
+ /** Vector length implied by a base64 payload, without decoding it. */
31406
+ function vectorDimFromBase64(encoded) {
31407
+ return Math.floor(Buffer.from(encoded, "base64").byteLength / 4);
31408
+ }
31409
+ object({
31410
+ /**
31411
+ * Fraction of the box's own size added on EACH side before cutting.
31412
+ *
31413
+ * CLIP is trained on natural images WITH surroundings; a pixel-tight crop
31414
+ * removes exactly the context it is strongest on (a dog cut to its outline
31415
+ * is a dark blob). The right value is an empirical question, which is why it
31416
+ * is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
31417
+ */
31418
+ paddingRatio: number().min(0).max(4),
31419
+ /**
31420
+ * Square the window (in PIXELS) before cutting.
31421
+ *
31422
+ * CLIP's input is square, so a tall bbox resized straight to NxN is squashed
31423
+ * — a standing person becomes a shape the model never saw. Squaring costs
31424
+ * extra background, which is context the model wants anyway. Off by default
31425
+ * because the live path has never squared and the stored index reflects that.
31426
+ */
31427
+ square: boolean()
31428
+ });
31429
+ ({
31430
+ paddingRatio: .15,
31431
+ square: false
31432
+ }).paddingRatio;
31394
31433
  //#endregion
31395
31434
  Object.defineProperty(exports, "BaseAddon", {
31396
31435
  enumerable: true,
@@ -31710,6 +31749,12 @@ Object.defineProperty(exports, "unknown", {
31710
31749
  return unknown;
31711
31750
  }
31712
31751
  });
31752
+ Object.defineProperty(exports, "vectorDimFromBase64", {
31753
+ enumerable: true,
31754
+ get: function() {
31755
+ return vectorDimFromBase64;
31756
+ }
31757
+ });
31713
31758
  Object.defineProperty(exports, "videoclipsCapability", {
31714
31759
  enumerable: true,
31715
31760
  get: function() {
@@ -15486,14 +15486,6 @@ var RebuildObjectEmbeddingsInput = object({
15486
15486
  deviceId: number().optional(),
15487
15487
  since: number().optional(),
15488
15488
  until: number().optional(),
15489
- /**
15490
- * Fraction added on EACH side of the detection box before cropping.
15491
- * ~0.2 (a 1.4x window) gives CLIP the surroundings it is trained on; 0 is
15492
- * the pixel-tight crop the first implementation used.
15493
- */
15494
- cropMargin: number().min(0).max(4).optional(),
15495
- /** Square the window before extracting, so a tall subject is not squashed. */
15496
- square: boolean().optional(),
15497
15489
  /** Stop after this many tracks; the result reports whether more remain. */
15498
15490
  maxTracks: number().int().positive().optional()
15499
15491
  });
@@ -15528,6 +15520,14 @@ var RebuildStatusSchema = object({
15528
15520
  missingKeyFrame: number(),
15529
15521
  /** Tracks with no usable detection box. */
15530
15522
  missingBbox: number(),
15523
+ /**
15524
+ * Tracks the pipeline REFUSED rather than broke on: the camera is not
15525
+ * attached, or `clip-embedding` is not enabled in its step tree. Separate
15526
+ * from `failed` because the remedy is a configuration change, not an engine
15527
+ * investigation — and because a pass over decommissioned cameras would
15528
+ * otherwise read as a total engine outage.
15529
+ */
15530
+ notRunnable: number(),
15531
15531
  failed: number(),
15532
15532
  /** Set once a pass ends: true only when EVERYTHING was covered. */
15533
15533
  complete: boolean().nullable(),
@@ -16778,6 +16778,17 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
16778
16778
  }), NativeCropResultSchema.nullable()), method(object({
16779
16779
  deviceId: number(),
16780
16780
  frameHandle: FrameHandleSchema.optional(),
16781
+ /**
16782
+ * FULL FRAME (base64 JPEG). The runner derives the crop rectangle from
16783
+ * `parent.bbox` with the cluster crop convention and cuts it itself —
16784
+ * do NOT pre-crop for this field, that is what `cropJpeg` is.
16785
+ */
16786
+ frameJpeg: string().optional(),
16787
+ /**
16788
+ * PRE-CUT tile (base64 JPEG), used verbatim — NO padding is applied.
16789
+ * The fallback when the lease/session backing the frame is gone and the
16790
+ * caller already holds a crop.
16791
+ */
16781
16792
  cropJpeg: string().optional(),
16782
16793
  parent: DetailParentSchema,
16783
16794
  steps: array(string()).optional()
@@ -31369,5 +31380,33 @@ function encodeVectorBase64(vector) {
31369
31380
  for (let i = 0; i < floats.length; i += 1) view.setFloat32(i * 4, floats[i] ?? 0, true);
31370
31381
  return Buffer.from(bytes).toString("base64");
31371
31382
  }
31383
+ /** Vector length implied by a base64 payload, without decoding it. */
31384
+ function vectorDimFromBase64(encoded) {
31385
+ return Math.floor(Buffer.from(encoded, "base64").byteLength / 4);
31386
+ }
31387
+ object({
31388
+ /**
31389
+ * Fraction of the box's own size added on EACH side before cutting.
31390
+ *
31391
+ * CLIP is trained on natural images WITH surroundings; a pixel-tight crop
31392
+ * removes exactly the context it is strongest on (a dog cut to its outline
31393
+ * is a dark blob). The right value is an empirical question, which is why it
31394
+ * is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
31395
+ */
31396
+ paddingRatio: number().min(0).max(4),
31397
+ /**
31398
+ * Square the window (in PIXELS) before cutting.
31399
+ *
31400
+ * CLIP's input is square, so a tall bbox resized straight to NxN is squashed
31401
+ * — a standing person becomes a shape the model never saw. Squaring costs
31402
+ * extra background, which is context the model wants anyway. Off by default
31403
+ * because the live path has never squared and the stored index reflects that.
31404
+ */
31405
+ square: boolean()
31406
+ });
31407
+ ({
31408
+ paddingRatio: .15,
31409
+ square: false
31410
+ }).paddingRatio;
31372
31411
  //#endregion
31373
- export { unknown as $, kebabToCamel as A, DeviceType as B, cosineSimilarity as C, encodeVectorBase64 as D, embeddingEncoderCapability as E, subKindsOf as F, _enum as G, hydrateSchema as H, videoclipsCapability as I, literal as J, array as K, zoneAnalyticsCapability as L, pipelineAnalyticsCapability as M, plateGalleryCapability as N, faceGalleryCapability as O, readDeviceStateFrom as P, string as Q, errMsg as R, buildEventKindDescriptor as S, defineCustomActions as T, isDeviceScopedCap as U, createEvent as V, nodePin as W, object as X, number as Y, record as Z, TimelapseRuleInputSchema as _, MACRO_LABELS as a, alarmPanelCapability as b, NcConditionDescriptorSchema as c, NcRuleSchema as d, EventCategory as et, NcSnoozeInputSchema as f, OpsLogEntrySchema as g, NcTaxonomySchema as h, EVENT_PAD_MS as i, notificationRulesCapability as j, hfModelUrl as k, NcRuleInputSchema as l, NcSnoozeSuppressedSchema as m, DEFAULT_EVENT_COLOR as n, NC_CONDITION_CATALOG as o, NcSnoozeSchema as p, boolean as q, EVENT_KIND_BY_CAP as r, NC_TAXONOMY as s, BaseDevice as t, NcRulePatchSchema as u, TimelapseRuleSchema as v, customAction as w, audioMetricsCapability as x, addonWidgetsSourceCapability as y, BaseAddon as z };
31412
+ export { string as $, kebabToCamel as A, BaseAddon as B, cosineSimilarity as C, encodeVectorBase64 as D, embeddingEncoderCapability as E, subKindsOf as F, nodePin as G, createEvent as H, vectorDimFromBase64 as I, boolean as J, _enum as K, videoclipsCapability as L, pipelineAnalyticsCapability as M, plateGalleryCapability as N, faceGalleryCapability as O, readDeviceStateFrom as P, record as Q, zoneAnalyticsCapability as R, buildEventKindDescriptor as S, defineCustomActions as T, hydrateSchema as U, DeviceType as V, isDeviceScopedCap as W, number as X, literal as Y, object as Z, TimelapseRuleInputSchema as _, MACRO_LABELS as a, alarmPanelCapability as b, NcConditionDescriptorSchema as c, NcRuleSchema as d, unknown as et, NcSnoozeInputSchema as f, OpsLogEntrySchema as g, NcTaxonomySchema as h, EVENT_PAD_MS as i, notificationRulesCapability as j, hfModelUrl as k, NcRuleInputSchema as l, NcSnoozeSuppressedSchema as m, DEFAULT_EVENT_COLOR as n, NC_CONDITION_CATALOG as o, NcSnoozeSchema as p, array as q, EVENT_KIND_BY_CAP as r, NC_TAXONOMY as s, BaseDevice as t, EventCategory as tt, NcRulePatchSchema as u, TimelapseRuleSchema as v, customAction as w, audioMetricsCapability as x, addonWidgetsSourceCapability as y, errMsg as z };
@@ -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-QzgUDrwU.js");
5
+ const require_dist = require("../dist-B5O4mNmd.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 { E as embeddingEncoderCapability, k as hfModelUrl, z as BaseAddon } from "../dist-BoV5RsRC.mjs";
1
+ import { B as BaseAddon, E as embeddingEncoderCapability, k as hfModelUrl } from "../dist-BHtH4Obt.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import * as fs from "node:fs";
4
4
  import "node:fs";
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-QzgUDrwU.js");
1
+ const require_dist = require("./dist-B5O4mNmd.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.2.40",
21
+ version: "1.2.41",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_pipeline_analytics_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.40",
39
+ version: "1.2.41",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -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-QzgUDrwU.js");
5
+ const require_dist = require("../dist-B5O4mNmd.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);
@@ -5845,14 +5845,6 @@ function makeNcActionHandlers(deps) {
5845
5845
  var RebuildInputSchema = require_dist.object({
5846
5846
  /** Restrict to one camera; omit for the whole fleet. */
5847
5847
  deviceId: require_dist.number().optional(),
5848
- /**
5849
- * Fraction added on EACH side of the detection box before cropping.
5850
- * ~0.2 (a 1.4x window) gives CLIP the surroundings it is trained on; 0 is
5851
- * the pixel-tight crop.
5852
- */
5853
- cropMargin: require_dist.number().min(0).max(4).optional(),
5854
- /** Square the window so a tall subject is not squashed into CLIP's input. */
5855
- square: require_dist.boolean().optional(),
5856
5848
  /** Stop after this many tracks; the status reports whether more remain. */
5857
5849
  maxTracks: require_dist.number().int().positive().optional()
5858
5850
  });
@@ -5866,6 +5858,8 @@ var RebuildStatusSchema = require_dist.object({
5866
5858
  rebuilt: require_dist.number(),
5867
5859
  missingKeyFrame: require_dist.number(),
5868
5860
  missingBbox: require_dist.number(),
5861
+ /** Refused by the pipeline (camera detached / step disabled), not broken. */
5862
+ notRunnable: require_dist.number(),
5869
5863
  failed: require_dist.number(),
5870
5864
  complete: require_dist.boolean().nullable(),
5871
5865
  startedAtMs: require_dist.number().nullable(),
@@ -12024,88 +12018,6 @@ var EmbeddingDispatcher = class {
12024
12018
  }
12025
12019
  }
12026
12020
  };
12027
- //#endregion
12028
- //#region src/pipeline-analytics/pipeline/bbox-pad.ts
12029
- /**
12030
- * Pad a normalized bounding box by a fractional amount relative to its own
12031
- * dimensions. All coordinates remain in the [0, 1] unit-square.
12032
- *
12033
- * Formula (before clamping):
12034
- * newX = x - padding * w
12035
- * newW = w + 2 * padding * w (i.e. w * (1 + 2 * padding))
12036
- * newY = y - padding * h
12037
- * newH = h + 2 * padding * h
12038
- *
12039
- * padding === 0 is the identity operation. Pure, immutable — always returns a
12040
- * new object.
12041
- *
12042
- * NOTE: clamping SHIFTS, it does not truncate — a window that overflows a
12043
- * bound slides inward and keeps the extent that was asked for, on either side.
12044
- * It shrinks only when the padded window is larger than the frame itself
12045
- * would (e.g. a left-hugging box gains a bit more right-side context). This is
12046
- * acceptable for detection crops (more context, never out of [0,1]); the
12047
- * downstream `extractCrop` clamps to pixel bounds again regardless.
12048
- */
12049
- function padBbox(bbox, padding) {
12050
- const rawX = bbox.x - padding * bbox.w;
12051
- const rawY = bbox.y - padding * bbox.h;
12052
- const rawW = bbox.w * (1 + 2 * padding);
12053
- const rawH = bbox.h * (1 + 2 * padding);
12054
- const w = Math.min(rawW, 1);
12055
- const h = Math.min(rawH, 1);
12056
- return {
12057
- x: Math.min(Math.max(0, rawX), 1 - w),
12058
- y: Math.min(Math.max(0, rawY), 1 - h),
12059
- w,
12060
- h
12061
- };
12062
- }
12063
- /**
12064
- * Widen a bbox and optionally square it, staying inside the unit frame.
12065
- *
12066
- * Squaring uses the frame's aspect so the window is square in PIXELS, not in
12067
- * normalised units — those are only the same on a square frame, and no camera
12068
- * produces one.
12069
- */
12070
- function cropWindow(bbox, frameWidth, frameHeight, options) {
12071
- const padded = padBbox(bbox, options.margin);
12072
- if (!options.square) return padded;
12073
- const pxW = padded.w * frameWidth;
12074
- const pxH = padded.h * frameHeight;
12075
- const side = Math.max(pxW, pxH);
12076
- const w = Math.min(side / frameWidth, 1);
12077
- const h = Math.min(side / frameHeight, 1);
12078
- const cx = padded.x + padded.w / 2;
12079
- const cy = padded.y + padded.h / 2;
12080
- return {
12081
- w,
12082
- h,
12083
- x: Math.min(Math.max(0, cx - w / 2), 1 - w),
12084
- y: Math.min(Math.max(0, cy - h / 2), 1 - h)
12085
- };
12086
- }
12087
- /** Cut a JPEG crop out of a JPEG frame using a normalised window. */
12088
- async function cropFromJpeg(jpeg, window) {
12089
- const image = (0, sharp.default)(jpeg);
12090
- const meta = await image.metadata();
12091
- const frameWidth = meta.width;
12092
- const frameHeight = meta.height;
12093
- if (frameWidth === void 0 || frameHeight === void 0) return null;
12094
- const left = Math.max(0, Math.min(Math.round(window.x * frameWidth), frameWidth - 1));
12095
- const top = Math.max(0, Math.min(Math.round(window.y * frameHeight), frameHeight - 1));
12096
- const width = Math.max(1, Math.min(Math.round(window.w * frameWidth), frameWidth - left));
12097
- const height = Math.max(1, Math.min(Math.round(window.h * frameHeight), frameHeight - top));
12098
- return {
12099
- crop: await image.extract({
12100
- left,
12101
- top,
12102
- width,
12103
- height
12104
- }).jpeg({ quality: 90 }).toBuffer(),
12105
- width,
12106
- height
12107
- };
12108
- }
12109
12021
  /**
12110
12022
  * Re-embed every listed track from its key frame.
12111
12023
  *
@@ -12114,12 +12026,11 @@ async function cropFromJpeg(jpeg, window) {
12114
12026
  * knowing how much was not rebuilt, not just how much was.
12115
12027
  */
12116
12028
  async function rebuildObjectEmbeddings(deps, options = {}) {
12117
- const margin = options.cropMargin ?? .2;
12118
- const square = options.square ?? true;
12119
12029
  const maxTracks = options.maxTracks ?? Number.POSITIVE_INFINITY;
12120
12030
  let rebuilt = 0;
12121
12031
  let missingKeyFrame = 0;
12122
12032
  let missingBbox = 0;
12033
+ let notRunnable = 0;
12123
12034
  let failed = 0;
12124
12035
  let scanned = 0;
12125
12036
  let cursor = null;
@@ -12150,33 +12061,40 @@ async function rebuildObjectEmbeddings(deps, options = {}) {
12150
12061
  missingBbox += 1;
12151
12062
  continue;
12152
12063
  }
12153
- const meta = await (0, sharp.default)(source.keyFrame).metadata();
12154
- if (meta.width === void 0 || meta.height === void 0) {
12155
- failed += 1;
12156
- continue;
12157
- }
12158
- const window = cropWindow(source.bbox, meta.width, meta.height, {
12159
- margin,
12160
- square
12161
- });
12162
- const cut = await cropFromJpeg(source.keyFrame, window);
12163
- if (cut === null) {
12164
- failed += 1;
12064
+ const outcome = await deps.embed(source);
12065
+ if (outcome.kind === "not-runnable") {
12066
+ notRunnable += 1;
12067
+ deps.logger.warn("embedding rebuild: track could not be re-embedded by the pipeline", {
12068
+ tags: { deviceId: source.deviceId },
12069
+ meta: {
12070
+ trackId,
12071
+ reason: outcome.reason
12072
+ }
12073
+ });
12165
12074
  continue;
12166
12075
  }
12167
- const embedding = await deps.encode(cut.crop, cut.width, cut.height);
12168
- if (embedding.length === 0) {
12076
+ if (outcome.kind === "failed") {
12169
12077
  failed += 1;
12078
+ deps.logger.warn("embedding rebuild: the pipeline returned no vector for a track", {
12079
+ tags: { deviceId: source.deviceId },
12080
+ meta: {
12081
+ trackId,
12082
+ reason: outcome.reason
12083
+ }
12084
+ });
12170
12085
  continue;
12171
12086
  }
12172
- const outcome = await deps.store(source, embedding);
12173
- if (outcome === "persisted") rebuilt += 1;
12087
+ const written = await deps.store(source, outcome.embeddingBase64);
12088
+ if (written === "persisted") rebuilt += 1;
12174
12089
  else {
12175
12090
  failed += 1;
12176
- deps.logger.warn("embedding rebuild wrote nothing for a track", { meta: {
12177
- trackId,
12178
- outcome
12179
- } });
12091
+ deps.logger.warn("embedding rebuild wrote nothing for a track", {
12092
+ tags: { deviceId: source.deviceId },
12093
+ meta: {
12094
+ trackId,
12095
+ outcome: written
12096
+ }
12097
+ });
12180
12098
  }
12181
12099
  } catch (err) {
12182
12100
  failed += 1;
@@ -12190,6 +12108,7 @@ async function rebuildObjectEmbeddings(deps, options = {}) {
12190
12108
  rebuilt,
12191
12109
  missingKeyFrame,
12192
12110
  missingBbox,
12111
+ notRunnable,
12193
12112
  failed
12194
12113
  });
12195
12114
  }
@@ -12205,16 +12124,16 @@ async function rebuildObjectEmbeddings(deps, options = {}) {
12205
12124
  rebuilt,
12206
12125
  missingKeyFrame,
12207
12126
  missingBbox,
12127
+ notRunnable,
12208
12128
  failed,
12209
- complete,
12210
- cropMargin: margin,
12211
- square
12129
+ complete
12212
12130
  } });
12213
12131
  return {
12214
12132
  scanned,
12215
12133
  rebuilt,
12216
12134
  missingKeyFrame,
12217
12135
  missingBbox,
12136
+ notRunnable,
12218
12137
  failed,
12219
12138
  complete
12220
12139
  };
@@ -12382,6 +12301,69 @@ async function resolveDefaultEventMedia(deps) {
12382
12301
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
12383
12302
  }
12384
12303
  //#endregion
12304
+ //#region src/pipeline-analytics/rebuild-detail-request.ts
12305
+ /** The one step a rebuild runs. */
12306
+ var REBUILD_DETAIL_STEP_ID = "clip-embedding";
12307
+ /**
12308
+ * Build the `runDetailSubtree` input for one stored track.
12309
+ *
12310
+ * The box is stored NORMALISED because the key frame is a native-resolution
12311
+ * capture while the detection ran on a downscaled analysis frame; the two have
12312
+ * different pixel dimensions. `parent.bbox` is frame pixels (the live path's
12313
+ * convention), so it is denormalised here against the KEY FRAME's own
12314
+ * dimensions — the image the runner is about to cut. This is coordinate
12315
+ * mapping, not crop derivation: no padding, no squaring, no rectangle choice.
12316
+ */
12317
+ function buildRebuildDetailInput(source, keyFrame) {
12318
+ return {
12319
+ deviceId: source.deviceId,
12320
+ frameJpeg: source.keyFrame.toString("base64"),
12321
+ parent: {
12322
+ bbox: {
12323
+ x: source.bbox.x * keyFrame.width,
12324
+ y: source.bbox.y * keyFrame.height,
12325
+ w: source.bbox.w * keyFrame.width,
12326
+ h: source.bbox.h * keyFrame.height
12327
+ },
12328
+ className: source.className
12329
+ },
12330
+ steps: [REBUILD_DETAIL_STEP_ID]
12331
+ };
12332
+ }
12333
+ /**
12334
+ * Turn one `runDetailSubtree` answer into a rebuild outcome.
12335
+ *
12336
+ * The three cases lead to three different counters:
12337
+ *
12338
+ * - `null` — the runner DECLINED: the camera is not attached, `clip-embedding`
12339
+ * is not enabled in its tree, or the frame was unreadable. It logs which,
12340
+ * tagged with the deviceId. That is `not-runnable`, not a failure: the remedy
12341
+ * is a configuration change, and folding it into `failed` would point the
12342
+ * operator at the inference engine instead.
12343
+ * - details with no `clip-embedding` embedding — the step RAN and produced
12344
+ * nothing usable. That is a failure.
12345
+ * - an embedding — passed through as base64, the form the vector store stores.
12346
+ */
12347
+ function readRebuildEmbedding(result) {
12348
+ if (result === null) return {
12349
+ kind: "not-runnable",
12350
+ reason: "the runner returned no result — camera not attached, clip-embedding not enabled for it, or the key frame was unreadable (the runner logged which, tagged with the deviceId)"
12351
+ };
12352
+ const embedding = result.details.find((d) => d.stepId === "clip-embedding" && d.embedding !== void 0)?.embedding;
12353
+ if (embedding === void 0) return {
12354
+ kind: "failed",
12355
+ reason: `the detail subtree ran but returned no ${REBUILD_DETAIL_STEP_ID} embedding (${result.details.length} detail(s))`
12356
+ };
12357
+ return {
12358
+ kind: "embedded",
12359
+ embeddingBase64: embedding
12360
+ };
12361
+ }
12362
+ /** Narrowing helper — a source without a key frame can never build a request. */
12363
+ function hasKeyFrame(source) {
12364
+ return source.keyFrame !== null && source.bbox !== null;
12365
+ }
12366
+ //#endregion
12385
12367
  //#region src/pipeline-analytics/pipeline/face-matcher.ts
12386
12368
  /**
12387
12369
  * Count enrolled samples per identity for probes of a matching model+dimension.
@@ -14573,6 +14555,42 @@ async function encodeKeyFrameVariants(native) {
14573
14555
  };
14574
14556
  }
14575
14557
  //#endregion
14558
+ //#region src/pipeline-analytics/pipeline/bbox-pad.ts
14559
+ /**
14560
+ * Pad a normalized bounding box by a fractional amount relative to its own
14561
+ * dimensions. All coordinates remain in the [0, 1] unit-square.
14562
+ *
14563
+ * Formula (before clamping):
14564
+ * newX = x - padding * w
14565
+ * newW = w + 2 * padding * w (i.e. w * (1 + 2 * padding))
14566
+ * newY = y - padding * h
14567
+ * newH = h + 2 * padding * h
14568
+ *
14569
+ * padding === 0 is the identity operation. Pure, immutable — always returns a
14570
+ * new object.
14571
+ *
14572
+ * NOTE: clamping SHIFTS, it does not truncate — a window that overflows a
14573
+ * bound slides inward and keeps the extent that was asked for, on either side.
14574
+ * It shrinks only when the padded window is larger than the frame itself
14575
+ * would (e.g. a left-hugging box gains a bit more right-side context). This is
14576
+ * acceptable for detection crops (more context, never out of [0,1]); the
14577
+ * downstream `extractCrop` clamps to pixel bounds again regardless.
14578
+ */
14579
+ function padBbox(bbox, padding) {
14580
+ const rawX = bbox.x - padding * bbox.w;
14581
+ const rawY = bbox.y - padding * bbox.h;
14582
+ const rawW = bbox.w * (1 + 2 * padding);
14583
+ const rawH = bbox.h * (1 + 2 * padding);
14584
+ const w = Math.min(rawW, 1);
14585
+ const h = Math.min(rawH, 1);
14586
+ return {
14587
+ x: Math.min(Math.max(0, rawX), 1 - w),
14588
+ y: Math.min(Math.max(0, rawY), 1 - h),
14589
+ w,
14590
+ h
14591
+ };
14592
+ }
14593
+ //#endregion
14576
14594
  //#region src/pipeline-analytics/pipeline/keyframe-crop.ts
14577
14595
  function createKeyFrameCrop(deps) {
14578
14596
  return async (input) => {
@@ -18562,30 +18580,18 @@ function buildGlobalSettingsSchema() {
18562
18580
  {
18563
18581
  id: "embedding-index",
18564
18582
  title: "Semantic search index",
18565
- description: "The CLIP vector index behind text search. Rebuild re-embeds stored tracks from their native key frames — the only way to change what a vector means, since a new crop margin or encoder model leaves two incomparable feature spaces in one index. Vectors live in the vector-store capability, alongside the events they are shown with.",
18583
+ description: "The CLIP vector index behind text search. Rebuild re-embeds stored tracks from their native key frames — the only way to change what a vector means, since a new crop convention or encoder model leaves two incomparable feature spaces in one index. Vectors live in the vector-store capability, alongside the events they are shown with.",
18566
18584
  columns: 1,
18567
18585
  fields: [
18568
- {
18569
- type: "number",
18570
- key: "rebuildCropMargin",
18571
- label: "Rebuild crop margin",
18572
- description: "Fraction added on EACH side of the detection box when re-embedding. 0.2 gives a 1.4x window: CLIP is trained on natural images WITH surroundings, and a pixel-tight crop removes what it is strongest on. 0 reproduces the tight crop. Only affects Rebuild — it does not change live capture, and the thumbnail is never re-cropped.",
18573
- default: .2,
18574
- min: 0,
18575
- max: 2,
18576
- step: .1
18577
- },
18578
18586
  {
18579
18587
  type: "addon-action-button",
18580
18588
  key: "rebuildEmbeddingsButton",
18581
18589
  label: "Rebuild index",
18582
- description: "Re-embed every stored track from its key frame, using the margin above. Runs in the background — press Progress to watch it. Tracks whose key frame has aged out cannot be rebuilt and are reported as skipped.",
18590
+ description: "Re-embed every stored track from its key frame, through the detection pipeline, using the cluster Enrichment crop setting — so a rebuilt vector is comparable with a live one. Runs in the background; press Progress to watch it. Tracks whose key frame has aged out, or whose camera is detached, are reported as skipped rather than dropped.",
18583
18591
  addonId: "pipeline-analytics",
18584
18592
  action: "embedding.rebuild",
18585
18593
  buttonLabel: "Rebuild",
18586
18594
  variant: "primary",
18587
- paramsFromForm: { cropMargin: "rebuildCropMargin" },
18588
- paramsStatic: { square: true },
18589
18595
  successMessage: "Rebuild started — press Progress to follow it."
18590
18596
  },
18591
18597
  {
@@ -18596,7 +18602,7 @@ function buildGlobalSettingsSchema() {
18596
18602
  addonId: "pipeline-analytics",
18597
18603
  action: "embedding.rebuildStatus",
18598
18604
  buttonLabel: "Check progress",
18599
- successMessage: "Indexed {indexed} · scanned {scanned} · rebuilt {rebuilt} · no key frame {missingKeyFrame} · failed {failed}"
18605
+ successMessage: "Indexed {indexed} · scanned {scanned} · rebuilt {rebuilt} · no key frame {missingKeyFrame} · not runnable {notRunnable} · failed {failed}"
18600
18606
  },
18601
18607
  {
18602
18608
  type: "addon-action-button",
@@ -20588,6 +20594,29 @@ var IdentityStore = class {
20588
20594
  */
20589
20595
  /** The vector-store index this store owns. */
20590
20596
  var OBJECT_CLIP_INDEX = "object-clip";
20597
+ /**
20598
+ * Reduce either input form to the stored form.
20599
+ *
20600
+ * A base64 payload passes through untouched; its dimension is read from the
20601
+ * byte length. A length that is not a whole number of float32s cannot be a
20602
+ * vector, and storing it would produce a row whose dimension silently
20603
+ * disagrees with the declared index.
20604
+ */
20605
+ function resolveVectorPayload(input) {
20606
+ if (input.embeddingBase64 !== void 0) {
20607
+ const byteLength = Buffer.from(input.embeddingBase64, "base64").byteLength;
20608
+ if (byteLength === 0 || byteLength % 4 !== 0) return null;
20609
+ return {
20610
+ base64: input.embeddingBase64,
20611
+ dim: require_dist.vectorDimFromBase64(input.embeddingBase64)
20612
+ };
20613
+ }
20614
+ if (input.embedding.length === 0) return null;
20615
+ return {
20616
+ base64: require_dist.encodeVectorBase64(input.embedding),
20617
+ dim: input.embedding.length
20618
+ };
20619
+ }
20591
20620
  var ObjectEmbeddingStore = class {
20592
20621
  vectorStore;
20593
20622
  logger;
@@ -20654,8 +20683,9 @@ var ObjectEmbeddingStore = class {
20654
20683
  * and dropped", because a counter that cannot reads as verification.
20655
20684
  */
20656
20685
  async upsertIfBetter(input) {
20657
- if (input.embedding.length === 0) return "failed";
20658
- if (!await this.ensureIndex(input.embedding.length)) return "failed";
20686
+ const vector = resolveVectorPayload(input);
20687
+ if (vector === null) return "failed";
20688
+ if (!await this.ensureIndex(vector.dim)) return "failed";
20659
20689
  try {
20660
20690
  if (input.force !== true) {
20661
20691
  const stored = (await this.vectorStore.getByIds.query({
@@ -20668,7 +20698,7 @@ var ObjectEmbeddingStore = class {
20668
20698
  index: OBJECT_CLIP_INDEX,
20669
20699
  items: [{
20670
20700
  id: input.trackId,
20671
- vector: require_dist.encodeVectorBase64(input.embedding),
20701
+ vector: vector.base64,
20672
20702
  metadata: metadataFor(input)
20673
20703
  }]
20674
20704
  });
@@ -20677,7 +20707,7 @@ var ObjectEmbeddingStore = class {
20677
20707
  tags: { deviceId: input.deviceId },
20678
20708
  meta: {
20679
20709
  trackId: input.trackId,
20680
- dim: input.embedding.length,
20710
+ dim: vector.dim,
20681
20711
  rejected: result.rejected
20682
20712
  }
20683
20713
  });
@@ -23979,7 +24009,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
23979
24009
  let storage = this.ctx.kernel.storage;
23980
24010
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
23981
24011
  if (mediaRoot) {
23982
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-HrEN5lB7.js"));
24012
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-m6HS7TOO.js"));
23983
24013
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
23984
24014
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
23985
24015
  }
@@ -27043,6 +27073,43 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
27043
27073
  return info.modelId;
27044
27074
  }
27045
27075
  /**
27076
+ * Re-embed ONE stored track by running its `clip-embedding` step through the
27077
+ * camera's runner.
27078
+ *
27079
+ * The whole point: the runner cuts. This method reads the key frame's pixel
27080
+ * dimensions (metadata only — no pixel work, no rectangle chosen here) so the
27081
+ * stored normalised box can be expressed in the key frame's own pixels, ships
27082
+ * the full frame, and reads the vector back. Where the crop boundary falls is
27083
+ * decided inside `runDetailSubtree` by the one cluster-wide convention, the
27084
+ * same call the live detail plane makes.
27085
+ *
27086
+ * NOT node-pinned: `runDetailSubtree` is device-bound, so the cap router
27087
+ * sends it to the runner that owns this camera. A pin would be a guess about
27088
+ * placement that the binding already knows.
27089
+ */
27090
+ async embedRebuildSourceViaPipeline(source) {
27091
+ if (!hasKeyFrame(source)) return {
27092
+ kind: "failed",
27093
+ reason: "no key frame or no bbox"
27094
+ };
27095
+ const runner = this.ctx.api?.pipelineRunner;
27096
+ if (!runner?.runDetailSubtree) return {
27097
+ kind: "not-runnable",
27098
+ reason: "pipelineRunner.runDetailSubtree is unavailable on this cluster"
27099
+ };
27100
+ const sharp$7 = (await import("sharp")).default;
27101
+ const meta = await sharp$7(source.keyFrame).metadata();
27102
+ if (meta.width === void 0 || meta.height === void 0) return {
27103
+ kind: "failed",
27104
+ reason: "key frame has no readable dimensions"
27105
+ };
27106
+ const input = buildRebuildDetailInput(source, {
27107
+ width: meta.width,
27108
+ height: meta.height
27109
+ });
27110
+ return readRebuildEmbedding(await runner.runDetailSubtree.mutate(input));
27111
+ }
27112
+ /**
27046
27113
  * Every track id the track store still holds, across the given devices.
27047
27114
  *
27048
27115
  * Returned as a Set for the orphan sweep, which REFUSES an empty one — a
@@ -27081,6 +27148,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
27081
27148
  * resolution, captured at the best detection) plus that detection's bbox, so
27082
27149
  * the window can be WIDENED. The tight crop originally embedded cannot be
27083
27150
  * widened after the fact.
27151
+ *
27152
+ * The frame and the box are shipped to the camera's RUNNER, which cuts and
27153
+ * encodes them through `runDetailSubtree` — the same code path, the same
27154
+ * crop convention and the same step catalog the live detail plane uses. This
27155
+ * side no longer chooses a rectangle, and there is no `cropMargin` argument
27156
+ * to choose one with: see `embedding-rebuild.ts` for what that cost.
27084
27157
  */
27085
27158
  async rebuildObjectEmbeddings(input) {
27086
27159
  if (this.rebuildState.running) {
@@ -27138,19 +27211,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
27138
27211
  ...keyFrameFile !== void 0 ? { keyFrameMediaKey: keyFrameFile.key } : {}
27139
27212
  };
27140
27213
  },
27141
- encode: async (crop, width, height) => {
27142
- return (await api.embeddingEncoder.encode.query({
27143
- crop: new Uint8Array(crop),
27144
- width,
27145
- height
27146
- })).embedding;
27147
- },
27148
- store: async (source, embedding) => store.upsertIfBetter({
27214
+ embed: (source) => this.embedRebuildSourceViaPipeline(source),
27215
+ store: async (source, embeddingBase64) => store.upsertIfBetter({
27149
27216
  trackId: source.trackId,
27150
27217
  deviceId: source.deviceId,
27151
27218
  timestamp: source.timestamp,
27152
27219
  className: source.className,
27153
- embedding,
27220
+ embeddingBase64,
27154
27221
  modelId,
27155
27222
  confidence: source.confidence,
27156
27223
  force: true,
@@ -27170,12 +27237,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
27170
27237
  rebuilt: 0,
27171
27238
  missingKeyFrame: 0,
27172
27239
  missingBbox: 0,
27240
+ notRunnable: 0,
27173
27241
  failed: 0
27174
27242
  }
27175
27243
  };
27176
27244
  rebuildObjectEmbeddings(deps, {
27177
- ...input.cropMargin !== void 0 ? { cropMargin: input.cropMargin } : {},
27178
- ...input.square !== void 0 ? { square: input.square } : {},
27179
27245
  ...input.maxTracks !== void 0 ? { maxTracks: input.maxTracks } : {},
27180
27246
  onProgress: (progress) => {
27181
27247
  if (this.rebuildState.running) this.rebuildState.progress = progress;
@@ -27200,7 +27266,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
27200
27266
  });
27201
27267
  this.ctx.logger.info("object embedding rebuild started", { meta: {
27202
27268
  deviceId: input.deviceId ?? "all",
27203
- cropMargin: input.cropMargin ?? "default",
27204
27269
  maxTracks: input.maxTracks ?? "unbounded"
27205
27270
  } });
27206
27271
  return {
@@ -27242,6 +27307,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
27242
27307
  rebuilt: p?.rebuilt ?? 0,
27243
27308
  missingKeyFrame: p?.missingKeyFrame ?? 0,
27244
27309
  missingBbox: p?.missingBbox ?? 0,
27310
+ notRunnable: p?.notRunnable ?? 0,
27245
27311
  failed: p?.failed ?? 0,
27246
27312
  complete: state.complete ?? null,
27247
27313
  startedAtMs: state.startedAtMs ?? null,
@@ -1,4 +1,4 @@
1
- import { $ as unknown, A as kebabToCamel, B as DeviceType, C as cosineSimilarity, D as encodeVectorBase64, F as subKindsOf, G as _enum, H as hydrateSchema, I as videoclipsCapability, J as literal, K as array, L as zoneAnalyticsCapability, M as pipelineAnalyticsCapability, N as plateGalleryCapability, O as faceGalleryCapability, P as readDeviceStateFrom, Q as string, R as errMsg, S as buildEventKindDescriptor, T as defineCustomActions, U as isDeviceScopedCap, V as createEvent, W as nodePin, X as object, Y as number, Z as record, _ as TimelapseRuleInputSchema, a as MACRO_LABELS, b as alarmPanelCapability, c as NcConditionDescriptorSchema, d as NcRuleSchema, et as EventCategory, f as NcSnoozeInputSchema, g as OpsLogEntrySchema, h as NcTaxonomySchema, i as EVENT_PAD_MS, j as notificationRulesCapability, l as NcRuleInputSchema, m as NcSnoozeSuppressedSchema, n as DEFAULT_EVENT_COLOR, o as NC_CONDITION_CATALOG, p as NcSnoozeSchema, q as boolean, r as EVENT_KIND_BY_CAP, s as NC_TAXONOMY, t as BaseDevice, u as NcRulePatchSchema, v as TimelapseRuleSchema, w as customAction, x as audioMetricsCapability, y as addonWidgetsSourceCapability, z as BaseAddon } from "../dist-BoV5RsRC.mjs";
1
+ import { $ as string, A as kebabToCamel, B as BaseAddon, C as cosineSimilarity, D as encodeVectorBase64, F as subKindsOf, G as nodePin, H as createEvent, I as vectorDimFromBase64, J as boolean, K as _enum, L as videoclipsCapability, M as pipelineAnalyticsCapability, N as plateGalleryCapability, O as faceGalleryCapability, P as readDeviceStateFrom, Q as record, R as zoneAnalyticsCapability, S as buildEventKindDescriptor, T as defineCustomActions, U as hydrateSchema, V as DeviceType, W as isDeviceScopedCap, X as number, Y as literal, Z as object, _ as TimelapseRuleInputSchema, a as MACRO_LABELS, b as alarmPanelCapability, c as NcConditionDescriptorSchema, d as NcRuleSchema, et as unknown, f as NcSnoozeInputSchema, g as OpsLogEntrySchema, h as NcTaxonomySchema, i as EVENT_PAD_MS, j as notificationRulesCapability, l as NcRuleInputSchema, m as NcSnoozeSuppressedSchema, n as DEFAULT_EVENT_COLOR, o as NC_CONDITION_CATALOG, p as NcSnoozeSchema, q as array, r as EVENT_KIND_BY_CAP, s as NC_TAXONOMY, t as BaseDevice, tt as EventCategory, u as NcRulePatchSchema, v as TimelapseRuleSchema, w as customAction, x as audioMetricsCapability, y as addonWidgetsSourceCapability, z as errMsg } from "../dist-BHtH4Obt.mjs";
2
2
  import { promises } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
@@ -5839,14 +5839,6 @@ function makeNcActionHandlers(deps) {
5839
5839
  var RebuildInputSchema = object({
5840
5840
  /** Restrict to one camera; omit for the whole fleet. */
5841
5841
  deviceId: number().optional(),
5842
- /**
5843
- * Fraction added on EACH side of the detection box before cropping.
5844
- * ~0.2 (a 1.4x window) gives CLIP the surroundings it is trained on; 0 is
5845
- * the pixel-tight crop.
5846
- */
5847
- cropMargin: number().min(0).max(4).optional(),
5848
- /** Square the window so a tall subject is not squashed into CLIP's input. */
5849
- square: boolean().optional(),
5850
5842
  /** Stop after this many tracks; the status reports whether more remain. */
5851
5843
  maxTracks: number().int().positive().optional()
5852
5844
  });
@@ -5860,6 +5852,8 @@ var RebuildStatusSchema = object({
5860
5852
  rebuilt: number(),
5861
5853
  missingKeyFrame: number(),
5862
5854
  missingBbox: number(),
5855
+ /** Refused by the pipeline (camera detached / step disabled), not broken. */
5856
+ notRunnable: number(),
5863
5857
  failed: number(),
5864
5858
  complete: boolean().nullable(),
5865
5859
  startedAtMs: number().nullable(),
@@ -12018,88 +12012,6 @@ var EmbeddingDispatcher = class {
12018
12012
  }
12019
12013
  }
12020
12014
  };
12021
- //#endregion
12022
- //#region src/pipeline-analytics/pipeline/bbox-pad.ts
12023
- /**
12024
- * Pad a normalized bounding box by a fractional amount relative to its own
12025
- * dimensions. All coordinates remain in the [0, 1] unit-square.
12026
- *
12027
- * Formula (before clamping):
12028
- * newX = x - padding * w
12029
- * newW = w + 2 * padding * w (i.e. w * (1 + 2 * padding))
12030
- * newY = y - padding * h
12031
- * newH = h + 2 * padding * h
12032
- *
12033
- * padding === 0 is the identity operation. Pure, immutable — always returns a
12034
- * new object.
12035
- *
12036
- * NOTE: clamping SHIFTS, it does not truncate — a window that overflows a
12037
- * bound slides inward and keeps the extent that was asked for, on either side.
12038
- * It shrinks only when the padded window is larger than the frame itself
12039
- * would (e.g. a left-hugging box gains a bit more right-side context). This is
12040
- * acceptable for detection crops (more context, never out of [0,1]); the
12041
- * downstream `extractCrop` clamps to pixel bounds again regardless.
12042
- */
12043
- function padBbox(bbox, padding) {
12044
- const rawX = bbox.x - padding * bbox.w;
12045
- const rawY = bbox.y - padding * bbox.h;
12046
- const rawW = bbox.w * (1 + 2 * padding);
12047
- const rawH = bbox.h * (1 + 2 * padding);
12048
- const w = Math.min(rawW, 1);
12049
- const h = Math.min(rawH, 1);
12050
- return {
12051
- x: Math.min(Math.max(0, rawX), 1 - w),
12052
- y: Math.min(Math.max(0, rawY), 1 - h),
12053
- w,
12054
- h
12055
- };
12056
- }
12057
- /**
12058
- * Widen a bbox and optionally square it, staying inside the unit frame.
12059
- *
12060
- * Squaring uses the frame's aspect so the window is square in PIXELS, not in
12061
- * normalised units — those are only the same on a square frame, and no camera
12062
- * produces one.
12063
- */
12064
- function cropWindow(bbox, frameWidth, frameHeight, options) {
12065
- const padded = padBbox(bbox, options.margin);
12066
- if (!options.square) return padded;
12067
- const pxW = padded.w * frameWidth;
12068
- const pxH = padded.h * frameHeight;
12069
- const side = Math.max(pxW, pxH);
12070
- const w = Math.min(side / frameWidth, 1);
12071
- const h = Math.min(side / frameHeight, 1);
12072
- const cx = padded.x + padded.w / 2;
12073
- const cy = padded.y + padded.h / 2;
12074
- return {
12075
- w,
12076
- h,
12077
- x: Math.min(Math.max(0, cx - w / 2), 1 - w),
12078
- y: Math.min(Math.max(0, cy - h / 2), 1 - h)
12079
- };
12080
- }
12081
- /** Cut a JPEG crop out of a JPEG frame using a normalised window. */
12082
- async function cropFromJpeg(jpeg, window) {
12083
- const image = sharp(jpeg);
12084
- const meta = await image.metadata();
12085
- const frameWidth = meta.width;
12086
- const frameHeight = meta.height;
12087
- if (frameWidth === void 0 || frameHeight === void 0) return null;
12088
- const left = Math.max(0, Math.min(Math.round(window.x * frameWidth), frameWidth - 1));
12089
- const top = Math.max(0, Math.min(Math.round(window.y * frameHeight), frameHeight - 1));
12090
- const width = Math.max(1, Math.min(Math.round(window.w * frameWidth), frameWidth - left));
12091
- const height = Math.max(1, Math.min(Math.round(window.h * frameHeight), frameHeight - top));
12092
- return {
12093
- crop: await image.extract({
12094
- left,
12095
- top,
12096
- width,
12097
- height
12098
- }).jpeg({ quality: 90 }).toBuffer(),
12099
- width,
12100
- height
12101
- };
12102
- }
12103
12015
  /**
12104
12016
  * Re-embed every listed track from its key frame.
12105
12017
  *
@@ -12108,12 +12020,11 @@ async function cropFromJpeg(jpeg, window) {
12108
12020
  * knowing how much was not rebuilt, not just how much was.
12109
12021
  */
12110
12022
  async function rebuildObjectEmbeddings(deps, options = {}) {
12111
- const margin = options.cropMargin ?? .2;
12112
- const square = options.square ?? true;
12113
12023
  const maxTracks = options.maxTracks ?? Number.POSITIVE_INFINITY;
12114
12024
  let rebuilt = 0;
12115
12025
  let missingKeyFrame = 0;
12116
12026
  let missingBbox = 0;
12027
+ let notRunnable = 0;
12117
12028
  let failed = 0;
12118
12029
  let scanned = 0;
12119
12030
  let cursor = null;
@@ -12144,33 +12055,40 @@ async function rebuildObjectEmbeddings(deps, options = {}) {
12144
12055
  missingBbox += 1;
12145
12056
  continue;
12146
12057
  }
12147
- const meta = await sharp(source.keyFrame).metadata();
12148
- if (meta.width === void 0 || meta.height === void 0) {
12149
- failed += 1;
12150
- continue;
12151
- }
12152
- const window = cropWindow(source.bbox, meta.width, meta.height, {
12153
- margin,
12154
- square
12155
- });
12156
- const cut = await cropFromJpeg(source.keyFrame, window);
12157
- if (cut === null) {
12158
- failed += 1;
12058
+ const outcome = await deps.embed(source);
12059
+ if (outcome.kind === "not-runnable") {
12060
+ notRunnable += 1;
12061
+ deps.logger.warn("embedding rebuild: track could not be re-embedded by the pipeline", {
12062
+ tags: { deviceId: source.deviceId },
12063
+ meta: {
12064
+ trackId,
12065
+ reason: outcome.reason
12066
+ }
12067
+ });
12159
12068
  continue;
12160
12069
  }
12161
- const embedding = await deps.encode(cut.crop, cut.width, cut.height);
12162
- if (embedding.length === 0) {
12070
+ if (outcome.kind === "failed") {
12163
12071
  failed += 1;
12072
+ deps.logger.warn("embedding rebuild: the pipeline returned no vector for a track", {
12073
+ tags: { deviceId: source.deviceId },
12074
+ meta: {
12075
+ trackId,
12076
+ reason: outcome.reason
12077
+ }
12078
+ });
12164
12079
  continue;
12165
12080
  }
12166
- const outcome = await deps.store(source, embedding);
12167
- if (outcome === "persisted") rebuilt += 1;
12081
+ const written = await deps.store(source, outcome.embeddingBase64);
12082
+ if (written === "persisted") rebuilt += 1;
12168
12083
  else {
12169
12084
  failed += 1;
12170
- deps.logger.warn("embedding rebuild wrote nothing for a track", { meta: {
12171
- trackId,
12172
- outcome
12173
- } });
12085
+ deps.logger.warn("embedding rebuild wrote nothing for a track", {
12086
+ tags: { deviceId: source.deviceId },
12087
+ meta: {
12088
+ trackId,
12089
+ outcome: written
12090
+ }
12091
+ });
12174
12092
  }
12175
12093
  } catch (err) {
12176
12094
  failed += 1;
@@ -12184,6 +12102,7 @@ async function rebuildObjectEmbeddings(deps, options = {}) {
12184
12102
  rebuilt,
12185
12103
  missingKeyFrame,
12186
12104
  missingBbox,
12105
+ notRunnable,
12187
12106
  failed
12188
12107
  });
12189
12108
  }
@@ -12199,16 +12118,16 @@ async function rebuildObjectEmbeddings(deps, options = {}) {
12199
12118
  rebuilt,
12200
12119
  missingKeyFrame,
12201
12120
  missingBbox,
12121
+ notRunnable,
12202
12122
  failed,
12203
- complete,
12204
- cropMargin: margin,
12205
- square
12123
+ complete
12206
12124
  } });
12207
12125
  return {
12208
12126
  scanned,
12209
12127
  rebuilt,
12210
12128
  missingKeyFrame,
12211
12129
  missingBbox,
12130
+ notRunnable,
12212
12131
  failed,
12213
12132
  complete
12214
12133
  };
@@ -12376,6 +12295,69 @@ async function resolveDefaultEventMedia(deps) {
12376
12295
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
12377
12296
  }
12378
12297
  //#endregion
12298
+ //#region src/pipeline-analytics/rebuild-detail-request.ts
12299
+ /** The one step a rebuild runs. */
12300
+ var REBUILD_DETAIL_STEP_ID = "clip-embedding";
12301
+ /**
12302
+ * Build the `runDetailSubtree` input for one stored track.
12303
+ *
12304
+ * The box is stored NORMALISED because the key frame is a native-resolution
12305
+ * capture while the detection ran on a downscaled analysis frame; the two have
12306
+ * different pixel dimensions. `parent.bbox` is frame pixels (the live path's
12307
+ * convention), so it is denormalised here against the KEY FRAME's own
12308
+ * dimensions — the image the runner is about to cut. This is coordinate
12309
+ * mapping, not crop derivation: no padding, no squaring, no rectangle choice.
12310
+ */
12311
+ function buildRebuildDetailInput(source, keyFrame) {
12312
+ return {
12313
+ deviceId: source.deviceId,
12314
+ frameJpeg: source.keyFrame.toString("base64"),
12315
+ parent: {
12316
+ bbox: {
12317
+ x: source.bbox.x * keyFrame.width,
12318
+ y: source.bbox.y * keyFrame.height,
12319
+ w: source.bbox.w * keyFrame.width,
12320
+ h: source.bbox.h * keyFrame.height
12321
+ },
12322
+ className: source.className
12323
+ },
12324
+ steps: [REBUILD_DETAIL_STEP_ID]
12325
+ };
12326
+ }
12327
+ /**
12328
+ * Turn one `runDetailSubtree` answer into a rebuild outcome.
12329
+ *
12330
+ * The three cases lead to three different counters:
12331
+ *
12332
+ * - `null` — the runner DECLINED: the camera is not attached, `clip-embedding`
12333
+ * is not enabled in its tree, or the frame was unreadable. It logs which,
12334
+ * tagged with the deviceId. That is `not-runnable`, not a failure: the remedy
12335
+ * is a configuration change, and folding it into `failed` would point the
12336
+ * operator at the inference engine instead.
12337
+ * - details with no `clip-embedding` embedding — the step RAN and produced
12338
+ * nothing usable. That is a failure.
12339
+ * - an embedding — passed through as base64, the form the vector store stores.
12340
+ */
12341
+ function readRebuildEmbedding(result) {
12342
+ if (result === null) return {
12343
+ kind: "not-runnable",
12344
+ reason: "the runner returned no result — camera not attached, clip-embedding not enabled for it, or the key frame was unreadable (the runner logged which, tagged with the deviceId)"
12345
+ };
12346
+ const embedding = result.details.find((d) => d.stepId === "clip-embedding" && d.embedding !== void 0)?.embedding;
12347
+ if (embedding === void 0) return {
12348
+ kind: "failed",
12349
+ reason: `the detail subtree ran but returned no ${REBUILD_DETAIL_STEP_ID} embedding (${result.details.length} detail(s))`
12350
+ };
12351
+ return {
12352
+ kind: "embedded",
12353
+ embeddingBase64: embedding
12354
+ };
12355
+ }
12356
+ /** Narrowing helper — a source without a key frame can never build a request. */
12357
+ function hasKeyFrame(source) {
12358
+ return source.keyFrame !== null && source.bbox !== null;
12359
+ }
12360
+ //#endregion
12379
12361
  //#region src/pipeline-analytics/pipeline/face-matcher.ts
12380
12362
  /**
12381
12363
  * Count enrolled samples per identity for probes of a matching model+dimension.
@@ -14567,6 +14549,42 @@ async function encodeKeyFrameVariants(native) {
14567
14549
  };
14568
14550
  }
14569
14551
  //#endregion
14552
+ //#region src/pipeline-analytics/pipeline/bbox-pad.ts
14553
+ /**
14554
+ * Pad a normalized bounding box by a fractional amount relative to its own
14555
+ * dimensions. All coordinates remain in the [0, 1] unit-square.
14556
+ *
14557
+ * Formula (before clamping):
14558
+ * newX = x - padding * w
14559
+ * newW = w + 2 * padding * w (i.e. w * (1 + 2 * padding))
14560
+ * newY = y - padding * h
14561
+ * newH = h + 2 * padding * h
14562
+ *
14563
+ * padding === 0 is the identity operation. Pure, immutable — always returns a
14564
+ * new object.
14565
+ *
14566
+ * NOTE: clamping SHIFTS, it does not truncate — a window that overflows a
14567
+ * bound slides inward and keeps the extent that was asked for, on either side.
14568
+ * It shrinks only when the padded window is larger than the frame itself
14569
+ * would (e.g. a left-hugging box gains a bit more right-side context). This is
14570
+ * acceptable for detection crops (more context, never out of [0,1]); the
14571
+ * downstream `extractCrop` clamps to pixel bounds again regardless.
14572
+ */
14573
+ function padBbox(bbox, padding) {
14574
+ const rawX = bbox.x - padding * bbox.w;
14575
+ const rawY = bbox.y - padding * bbox.h;
14576
+ const rawW = bbox.w * (1 + 2 * padding);
14577
+ const rawH = bbox.h * (1 + 2 * padding);
14578
+ const w = Math.min(rawW, 1);
14579
+ const h = Math.min(rawH, 1);
14580
+ return {
14581
+ x: Math.min(Math.max(0, rawX), 1 - w),
14582
+ y: Math.min(Math.max(0, rawY), 1 - h),
14583
+ w,
14584
+ h
14585
+ };
14586
+ }
14587
+ //#endregion
14570
14588
  //#region src/pipeline-analytics/pipeline/keyframe-crop.ts
14571
14589
  function createKeyFrameCrop(deps) {
14572
14590
  return async (input) => {
@@ -18556,30 +18574,18 @@ function buildGlobalSettingsSchema() {
18556
18574
  {
18557
18575
  id: "embedding-index",
18558
18576
  title: "Semantic search index",
18559
- description: "The CLIP vector index behind text search. Rebuild re-embeds stored tracks from their native key frames — the only way to change what a vector means, since a new crop margin or encoder model leaves two incomparable feature spaces in one index. Vectors live in the vector-store capability, alongside the events they are shown with.",
18577
+ description: "The CLIP vector index behind text search. Rebuild re-embeds stored tracks from their native key frames — the only way to change what a vector means, since a new crop convention or encoder model leaves two incomparable feature spaces in one index. Vectors live in the vector-store capability, alongside the events they are shown with.",
18560
18578
  columns: 1,
18561
18579
  fields: [
18562
- {
18563
- type: "number",
18564
- key: "rebuildCropMargin",
18565
- label: "Rebuild crop margin",
18566
- description: "Fraction added on EACH side of the detection box when re-embedding. 0.2 gives a 1.4x window: CLIP is trained on natural images WITH surroundings, and a pixel-tight crop removes what it is strongest on. 0 reproduces the tight crop. Only affects Rebuild — it does not change live capture, and the thumbnail is never re-cropped.",
18567
- default: .2,
18568
- min: 0,
18569
- max: 2,
18570
- step: .1
18571
- },
18572
18580
  {
18573
18581
  type: "addon-action-button",
18574
18582
  key: "rebuildEmbeddingsButton",
18575
18583
  label: "Rebuild index",
18576
- description: "Re-embed every stored track from its key frame, using the margin above. Runs in the background — press Progress to watch it. Tracks whose key frame has aged out cannot be rebuilt and are reported as skipped.",
18584
+ description: "Re-embed every stored track from its key frame, through the detection pipeline, using the cluster Enrichment crop setting — so a rebuilt vector is comparable with a live one. Runs in the background; press Progress to watch it. Tracks whose key frame has aged out, or whose camera is detached, are reported as skipped rather than dropped.",
18577
18585
  addonId: "pipeline-analytics",
18578
18586
  action: "embedding.rebuild",
18579
18587
  buttonLabel: "Rebuild",
18580
18588
  variant: "primary",
18581
- paramsFromForm: { cropMargin: "rebuildCropMargin" },
18582
- paramsStatic: { square: true },
18583
18589
  successMessage: "Rebuild started — press Progress to follow it."
18584
18590
  },
18585
18591
  {
@@ -18590,7 +18596,7 @@ function buildGlobalSettingsSchema() {
18590
18596
  addonId: "pipeline-analytics",
18591
18597
  action: "embedding.rebuildStatus",
18592
18598
  buttonLabel: "Check progress",
18593
- successMessage: "Indexed {indexed} · scanned {scanned} · rebuilt {rebuilt} · no key frame {missingKeyFrame} · failed {failed}"
18599
+ successMessage: "Indexed {indexed} · scanned {scanned} · rebuilt {rebuilt} · no key frame {missingKeyFrame} · not runnable {notRunnable} · failed {failed}"
18594
18600
  },
18595
18601
  {
18596
18602
  type: "addon-action-button",
@@ -20582,6 +20588,29 @@ var IdentityStore = class {
20582
20588
  */
20583
20589
  /** The vector-store index this store owns. */
20584
20590
  var OBJECT_CLIP_INDEX = "object-clip";
20591
+ /**
20592
+ * Reduce either input form to the stored form.
20593
+ *
20594
+ * A base64 payload passes through untouched; its dimension is read from the
20595
+ * byte length. A length that is not a whole number of float32s cannot be a
20596
+ * vector, and storing it would produce a row whose dimension silently
20597
+ * disagrees with the declared index.
20598
+ */
20599
+ function resolveVectorPayload(input) {
20600
+ if (input.embeddingBase64 !== void 0) {
20601
+ const byteLength = Buffer.from(input.embeddingBase64, "base64").byteLength;
20602
+ if (byteLength === 0 || byteLength % 4 !== 0) return null;
20603
+ return {
20604
+ base64: input.embeddingBase64,
20605
+ dim: vectorDimFromBase64(input.embeddingBase64)
20606
+ };
20607
+ }
20608
+ if (input.embedding.length === 0) return null;
20609
+ return {
20610
+ base64: encodeVectorBase64(input.embedding),
20611
+ dim: input.embedding.length
20612
+ };
20613
+ }
20585
20614
  var ObjectEmbeddingStore = class {
20586
20615
  vectorStore;
20587
20616
  logger;
@@ -20648,8 +20677,9 @@ var ObjectEmbeddingStore = class {
20648
20677
  * and dropped", because a counter that cannot reads as verification.
20649
20678
  */
20650
20679
  async upsertIfBetter(input) {
20651
- if (input.embedding.length === 0) return "failed";
20652
- if (!await this.ensureIndex(input.embedding.length)) return "failed";
20680
+ const vector = resolveVectorPayload(input);
20681
+ if (vector === null) return "failed";
20682
+ if (!await this.ensureIndex(vector.dim)) return "failed";
20653
20683
  try {
20654
20684
  if (input.force !== true) {
20655
20685
  const stored = (await this.vectorStore.getByIds.query({
@@ -20662,7 +20692,7 @@ var ObjectEmbeddingStore = class {
20662
20692
  index: OBJECT_CLIP_INDEX,
20663
20693
  items: [{
20664
20694
  id: input.trackId,
20665
- vector: encodeVectorBase64(input.embedding),
20695
+ vector: vector.base64,
20666
20696
  metadata: metadataFor(input)
20667
20697
  }]
20668
20698
  });
@@ -20671,7 +20701,7 @@ var ObjectEmbeddingStore = class {
20671
20701
  tags: { deviceId: input.deviceId },
20672
20702
  meta: {
20673
20703
  trackId: input.trackId,
20674
- dim: input.embedding.length,
20704
+ dim: vector.dim,
20675
20705
  rejected: result.rejected
20676
20706
  }
20677
20707
  });
@@ -27037,6 +27067,43 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
27037
27067
  return info.modelId;
27038
27068
  }
27039
27069
  /**
27070
+ * Re-embed ONE stored track by running its `clip-embedding` step through the
27071
+ * camera's runner.
27072
+ *
27073
+ * The whole point: the runner cuts. This method reads the key frame's pixel
27074
+ * dimensions (metadata only — no pixel work, no rectangle chosen here) so the
27075
+ * stored normalised box can be expressed in the key frame's own pixels, ships
27076
+ * the full frame, and reads the vector back. Where the crop boundary falls is
27077
+ * decided inside `runDetailSubtree` by the one cluster-wide convention, the
27078
+ * same call the live detail plane makes.
27079
+ *
27080
+ * NOT node-pinned: `runDetailSubtree` is device-bound, so the cap router
27081
+ * sends it to the runner that owns this camera. A pin would be a guess about
27082
+ * placement that the binding already knows.
27083
+ */
27084
+ async embedRebuildSourceViaPipeline(source) {
27085
+ if (!hasKeyFrame(source)) return {
27086
+ kind: "failed",
27087
+ reason: "no key frame or no bbox"
27088
+ };
27089
+ const runner = this.ctx.api?.pipelineRunner;
27090
+ if (!runner?.runDetailSubtree) return {
27091
+ kind: "not-runnable",
27092
+ reason: "pipelineRunner.runDetailSubtree is unavailable on this cluster"
27093
+ };
27094
+ const sharp = (await import("sharp")).default;
27095
+ const meta = await sharp(source.keyFrame).metadata();
27096
+ if (meta.width === void 0 || meta.height === void 0) return {
27097
+ kind: "failed",
27098
+ reason: "key frame has no readable dimensions"
27099
+ };
27100
+ const input = buildRebuildDetailInput(source, {
27101
+ width: meta.width,
27102
+ height: meta.height
27103
+ });
27104
+ return readRebuildEmbedding(await runner.runDetailSubtree.mutate(input));
27105
+ }
27106
+ /**
27040
27107
  * Every track id the track store still holds, across the given devices.
27041
27108
  *
27042
27109
  * Returned as a Set for the orphan sweep, which REFUSES an empty one — a
@@ -27075,6 +27142,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
27075
27142
  * resolution, captured at the best detection) plus that detection's bbox, so
27076
27143
  * the window can be WIDENED. The tight crop originally embedded cannot be
27077
27144
  * widened after the fact.
27145
+ *
27146
+ * The frame and the box are shipped to the camera's RUNNER, which cuts and
27147
+ * encodes them through `runDetailSubtree` — the same code path, the same
27148
+ * crop convention and the same step catalog the live detail plane uses. This
27149
+ * side no longer chooses a rectangle, and there is no `cropMargin` argument
27150
+ * to choose one with: see `embedding-rebuild.ts` for what that cost.
27078
27151
  */
27079
27152
  async rebuildObjectEmbeddings(input) {
27080
27153
  if (this.rebuildState.running) {
@@ -27132,19 +27205,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
27132
27205
  ...keyFrameFile !== void 0 ? { keyFrameMediaKey: keyFrameFile.key } : {}
27133
27206
  };
27134
27207
  },
27135
- encode: async (crop, width, height) => {
27136
- return (await api.embeddingEncoder.encode.query({
27137
- crop: new Uint8Array(crop),
27138
- width,
27139
- height
27140
- })).embedding;
27141
- },
27142
- store: async (source, embedding) => store.upsertIfBetter({
27208
+ embed: (source) => this.embedRebuildSourceViaPipeline(source),
27209
+ store: async (source, embeddingBase64) => store.upsertIfBetter({
27143
27210
  trackId: source.trackId,
27144
27211
  deviceId: source.deviceId,
27145
27212
  timestamp: source.timestamp,
27146
27213
  className: source.className,
27147
- embedding,
27214
+ embeddingBase64,
27148
27215
  modelId,
27149
27216
  confidence: source.confidence,
27150
27217
  force: true,
@@ -27164,12 +27231,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
27164
27231
  rebuilt: 0,
27165
27232
  missingKeyFrame: 0,
27166
27233
  missingBbox: 0,
27234
+ notRunnable: 0,
27167
27235
  failed: 0
27168
27236
  }
27169
27237
  };
27170
27238
  rebuildObjectEmbeddings(deps, {
27171
- ...input.cropMargin !== void 0 ? { cropMargin: input.cropMargin } : {},
27172
- ...input.square !== void 0 ? { square: input.square } : {},
27173
27239
  ...input.maxTracks !== void 0 ? { maxTracks: input.maxTracks } : {},
27174
27240
  onProgress: (progress) => {
27175
27241
  if (this.rebuildState.running) this.rebuildState.progress = progress;
@@ -27194,7 +27260,6 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
27194
27260
  });
27195
27261
  this.ctx.logger.info("object embedding rebuild started", { meta: {
27196
27262
  deviceId: input.deviceId ?? "all",
27197
- cropMargin: input.cropMargin ?? "default",
27198
27263
  maxTracks: input.maxTracks ?? "unbounded"
27199
27264
  } });
27200
27265
  return {
@@ -27236,6 +27301,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
27236
27301
  rebuilt: p?.rebuilt ?? 0,
27237
27302
  missingKeyFrame: p?.missingKeyFrame ?? 0,
27238
27303
  missingBbox: p?.missingBbox ?? 0,
27304
+ notRunnable: p?.notRunnable ?? 0,
27239
27305
  failed: p?.failed ?? 0,
27240
27306
  complete: state.complete ?? null,
27241
27307
  startedAtMs: state.startedAtMs ?? null,
@@ -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-DvfzPjZx.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Cqisi0Ci.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.46",
3
+ "version": "1.2.47",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",