@camstack/addon-pipeline 1.1.64 → 1.1.66

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.
Files changed (42) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +188 -26
  4. package/dist/detection-pipeline/index.mjs +188 -26
  5. package/dist/{dist-BzVZ2I4P.mjs → dist-BpWot5-w.mjs} +13 -55
  6. package/dist/{dist-B2jTt7Lq.js → dist-D5o9fGxq.js} +13 -55
  7. package/dist/motion-wasm/index.js +1 -1
  8. package/dist/motion-wasm/index.mjs +1 -1
  9. package/dist/pipeline-runner/index.js +88 -22
  10. package/dist/pipeline-runner/index.mjs +88 -22
  11. package/dist/recorder/index.js +141 -1
  12. package/dist/recorder/index.mjs +141 -1
  13. package/dist/session-decode/decode-worker-child.js +9 -3
  14. package/dist/session-decode/decode-worker-child.mjs +9 -3
  15. package/dist/{step-definitions-DXkY5Zor.js → step-definitions-81eYyJbW.js} +138 -8
  16. package/dist/{step-definitions-B07M9qhj.mjs → step-definitions-D6R9X0V7.mjs} +138 -8
  17. package/dist/stream-broker/_stub.js +2 -2
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-dTMki_1D.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CIIIVPzB.mjs} +2 -2
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CPbLcza9.mjs +26 -0
  20. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DlALQ_wp.mjs +26 -0
  21. package/dist/stream-broker/{hostInit-BY2oQAEX.mjs → hostInit-CeBNudbY.mjs} +2 -2
  22. package/dist/stream-broker/index.js +51 -3
  23. package/dist/stream-broker/index.mjs +51 -3
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-C03fx7Sp.js → MaskShapeCanvas-DI4BY7W2-CbBbcdZb.js} +1 -1
  26. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BTxzn1rQ.js → MotionZonesSettings-NcxxQN8r-aA6zvms_.js} +1 -1
  27. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-GKO8O7rC.js → PrivacyMaskSettings-APgPLF7p-CcGB7iAE.js} +1 -1
  28. package/embed-dist/assets/index-BRG_Fqb5.js +114 -0
  29. package/embed-dist/assets/index-D2pF9Z3W.css +2 -0
  30. package/embed-dist/index.html +2 -2
  31. package/package.json +1 -1
  32. package/python/inference_pool.py +30 -0
  33. package/python/postprocessors/scrfd.py +4 -1
  34. package/python/postprocessors/softmax.py +17 -5
  35. package/python/postprocessors/test_softmax.py +52 -0
  36. package/python/postprocessors/test_yolo_nms.py +45 -0
  37. package/python/postprocessors/yolo.py +4 -1
  38. package/python/postprocessors/yolo_seg.py +4 -1
  39. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-bAh96WNb.mjs +0 -26
  40. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CV2WbUaO.mjs +0 -26
  41. package/embed-dist/assets/index-Bm9LaQPH.css +0 -2
  42. package/embed-dist/assets/index-QdssILAA.js +0 -81
@@ -3,11 +3,11 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-D6vf50IK.js");
6
- const require_dist = require("../dist-B2jTt7Lq.js");
6
+ const require_dist = require("../dist-D5o9fGxq.js");
7
7
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
8
8
  const require_sheet_geometry = require("../sheet-geometry-BR-ip0CP.js");
9
9
  const require_worker_protocol = require("../worker-protocol-PP4jKHHJ.js");
10
- const require_step_definitions = require("../step-definitions-DXkY5Zor.js");
10
+ const require_step_definitions = require("../step-definitions-81eYyJbW.js");
11
11
  let node_child_process = require("node:child_process");
12
12
  let node_url = require("node:url");
13
13
  let sharp = require("sharp");
@@ -1248,23 +1248,39 @@ function startGuardedSessionDecode(deps) {
1248
1248
  /** {@link RETAINED_FRAME_BUDGET_MB} in bytes. */
1249
1249
  var RETAINED_FRAME_BUDGET_BYTES = (() => {
1250
1250
  const raw = Number(process.env["CAMSTACK_SESSION_RETAIN_MB"]);
1251
- return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 32;
1251
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 96;
1252
1252
  })() * 1024 * 1024;
1253
+ /**
1254
+ * How many of each device's MOST RECENT frames are protected from a busy
1255
+ * neighbour's budget eviction. Sized to cover the late two-plane / cross-process
1256
+ * enrichment horizon (~150-500 ms): 6 frames ≈ 0.6 s @10 fps of guaranteed
1257
+ * survival for every active camera. Read ONCE from
1258
+ * `CAMSTACK_SESSION_RETAIN_FLOOR`; `0` disables the floor (pure global FIFO — the
1259
+ * pre-2026-07-19 behaviour). The hard byte ceiling always overrides the floor,
1260
+ * so this can never push retention past {@link RETAINED_FRAME_BUDGET_BYTES}.
1261
+ */
1262
+ var RETAINED_FRAME_PER_DEVICE_FLOOR = (() => {
1263
+ const raw = Number(process.env["CAMSTACK_SESSION_RETAIN_FLOOR"]);
1264
+ return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 6;
1265
+ })();
1253
1266
  /** Opaque RAM-key prefix stamped into a retained frame's `FrameHandle.shmId`. */
1254
1267
  var RETAINED_FRAME_KEY_PREFIX = "ram:";
1255
1268
  /** A retention frame is uniquely identified by its key + slot + committed seq. */
1256
1269
  function keyOf(handle) {
1257
1270
  return `${handle.shmId}#${handle.slot}#${handle.seq}`;
1258
1271
  }
1259
- /**
1260
- * Node-level RAM store of retained rgb detection frames, keyed by
1261
- * `FrameHandle` identity. Latest-wins with a byte budget.
1262
- */
1263
1272
  var RetainedFrameStore = class RetainedFrameStore {
1264
1273
  nodeId;
1265
1274
  budgetBytes;
1266
- /** Insertion-ordered (Map) → oldest is `keys().next()`. */
1275
+ perDeviceFloor;
1276
+ /** Insertion-ordered (Map) → globally oldest is `keys().next()`. */
1267
1277
  entries = /* @__PURE__ */ new Map();
1278
+ /**
1279
+ * Per-device insertion-ordered keys (oldest→newest). Mirrors {@link entries}
1280
+ * so the floor can be enforced in O(1) per eviction candidate without scanning
1281
+ * the global map for a device's frame count.
1282
+ */
1283
+ perDeviceKeys = /* @__PURE__ */ new Map();
1268
1284
  bytes = 0;
1269
1285
  seq = 0;
1270
1286
  /** Ring width used only to spread the `slot` field; the map key is authoritative. */
@@ -1272,6 +1288,7 @@ var RetainedFrameStore = class RetainedFrameStore {
1272
1288
  constructor(options) {
1273
1289
  this.nodeId = options.nodeId;
1274
1290
  this.budgetBytes = options.budgetBytes ?? RETAINED_FRAME_BUDGET_BYTES;
1291
+ this.perDeviceFloor = options.perDeviceFloor ?? RETAINED_FRAME_PER_DEVICE_FLOOR;
1275
1292
  }
1276
1293
  /** Number of retained frames (tests / metrics). */
1277
1294
  get size() {
@@ -1304,14 +1321,21 @@ var RetainedFrameStore = class RetainedFrameStore {
1304
1321
  nodeId: this.nodeId,
1305
1322
  slotCount: RetainedFrameStore.SLOT_SPREAD
1306
1323
  };
1307
- this.entries.set(keyOf(handle), frame);
1324
+ const key = keyOf(handle);
1325
+ this.entries.set(key, {
1326
+ frame,
1327
+ deviceId
1328
+ });
1329
+ const deviceKeys = this.perDeviceKeys.get(deviceId);
1330
+ if (deviceKeys) deviceKeys.push(key);
1331
+ else this.perDeviceKeys.set(deviceId, [key]);
1308
1332
  this.bytes += byteLength;
1309
1333
  this.evictToBudget();
1310
1334
  return handle;
1311
1335
  }
1312
1336
  /** The retained frame for `handle`, or `null` if never retained / already evicted. */
1313
1337
  getFrame(handle) {
1314
- return this.entries.get(keyOf(handle)) ?? null;
1338
+ return this.entries.get(keyOf(handle))?.frame ?? null;
1315
1339
  }
1316
1340
  /**
1317
1341
  * Crop (and optionally downscale) the retained rgb frame for `handle` to the
@@ -1322,26 +1346,56 @@ var RetainedFrameStore = class RetainedFrameStore {
1322
1346
  * decode worker's surface (`getNativeCrop`), not here.
1323
1347
  */
1324
1348
  crop(handle, bbox, maxWidth) {
1325
- const frame = this.entries.get(keyOf(handle));
1326
- if (!frame || frame.format !== "rgb") return null;
1327
- return cropRgb(frame, bbox, maxWidth);
1349
+ const entry = this.entries.get(keyOf(handle));
1350
+ if (!entry || entry.frame.format !== "rgb") return null;
1351
+ return cropRgb(entry.frame, bbox, maxWidth);
1328
1352
  }
1329
1353
  /** Drop every retained frame (node/addon shutdown). */
1330
1354
  clear() {
1331
1355
  this.entries.clear();
1356
+ this.perDeviceKeys.clear();
1332
1357
  this.bytes = 0;
1333
1358
  }
1334
- /** Evict oldest frames until the retained bytes fit the budget (keep ≥1). */
1359
+ /**
1360
+ * Evict frames until the retained bytes fit the budget (keep ≥1).
1361
+ *
1362
+ * Pass 1 (fairness): walk globally oldest→newest and drop only frames whose
1363
+ * device still holds MORE than {@link perDeviceFloor} frames — so a busy
1364
+ * camera's excess is reclaimed before any camera's protected recent window.
1365
+ * Pass 2 (OOM guard): if still over budget (every remaining frame is
1366
+ * floor-protected yet the ceiling is exhausted), drop globally oldest-first
1367
+ * regardless of the floor. The ceiling ALWAYS wins over fairness.
1368
+ */
1335
1369
  evictToBudget() {
1336
- while (this.bytes > this.budgetBytes && this.entries.size > 1) {
1337
- const oldest = this.entries.keys().next().value;
1338
- if (oldest === void 0) break;
1339
- const evicted = this.entries.get(oldest);
1340
- this.entries.delete(oldest);
1341
- if (evicted) this.bytes -= evicted.data.byteLength;
1370
+ if (this.bytes <= this.budgetBytes) return;
1371
+ const oldestFirst = [...this.entries.keys()];
1372
+ for (const key of oldestFirst) {
1373
+ if (this.bytes <= this.budgetBytes || this.entries.size <= 1) break;
1374
+ const entry = this.entries.get(key);
1375
+ if (!entry) continue;
1376
+ const deviceKeys = this.perDeviceKeys.get(entry.deviceId);
1377
+ if (deviceKeys && deviceKeys.length > this.perDeviceFloor) this.dropKey(key);
1378
+ }
1379
+ if (this.bytes <= this.budgetBytes) return;
1380
+ for (const key of oldestFirst) {
1381
+ if (this.bytes <= this.budgetBytes || this.entries.size <= 1) break;
1382
+ this.dropKey(key);
1342
1383
  }
1343
1384
  if (this.bytes < 0) this.bytes = 0;
1344
1385
  }
1386
+ /** Remove one frame by key, keeping the byte tally + per-device index exact. */
1387
+ dropKey(key) {
1388
+ const entry = this.entries.get(key);
1389
+ if (!entry) return;
1390
+ this.entries.delete(key);
1391
+ this.bytes -= entry.frame.data.byteLength;
1392
+ const deviceKeys = this.perDeviceKeys.get(entry.deviceId);
1393
+ if (deviceKeys) {
1394
+ const idx = deviceKeys.indexOf(key);
1395
+ if (idx >= 0) deviceKeys.splice(idx, 1);
1396
+ if (deviceKeys.length === 0) this.perDeviceKeys.delete(entry.deviceId);
1397
+ }
1398
+ }
1345
1399
  };
1346
1400
  /**
1347
1401
  * Nearest-neighbour crop + optional downscale of a packed RGB24 frame to a
@@ -1931,6 +1985,18 @@ function startSessionDecodePump(deps) {
1931
1985
  iterator.return().catch(() => {});
1932
1986
  };
1933
1987
  }
1988
+ //#endregion
1989
+ //#region src/session-decode/native-crop-registry.ts
1990
+ /**
1991
+ * Default max entries retained across all active decode sessions on a node. Read
1992
+ * ONCE from `CAMSTACK_NATIVE_CROP_REGISTRY_CAP`; a non-finite / non-positive
1993
+ * value falls back to 256 (was a fixed 64 — too small for a busy multi-camera
1994
+ * node, see class doc).
1995
+ */
1996
+ var DEFAULT_NATIVE_CROP_REGISTRY_CAP = (() => {
1997
+ const raw = Number(process.env["CAMSTACK_NATIVE_CROP_REGISTRY_CAP"]);
1998
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 256;
1999
+ })();
1934
2000
  /** Invoke a requester's best-effort `release()`, swallowing any transport error. */
1935
2001
  function safeRelease(request) {
1936
2002
  try {
@@ -1940,7 +2006,7 @@ function safeRelease(request) {
1940
2006
  var NativeCropRegistry = class {
1941
2007
  capacity;
1942
2008
  entries = /* @__PURE__ */ new Map();
1943
- constructor(capacity = 64) {
2009
+ constructor(capacity = DEFAULT_NATIVE_CROP_REGISTRY_CAP) {
1944
2010
  this.capacity = capacity;
1945
2011
  }
1946
2012
  /** Number of registered handles (tests / metrics). */
@@ -3106,7 +3172,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
3106
3172
  */
3107
3173
  async getNativeCrop(input) {
3108
3174
  const b = input.bbox;
3109
- if (!(b.x <= .001 && b.y <= .001 && b.w >= .999 && b.h >= .999)) {
3175
+ if (!(b.x <= .001 && b.y <= .001 && b.w >= .999 && b.h >= .999 && input.maxWidth === void 0)) {
3110
3176
  const requester = this.nativeCropRegistry.get(input.handle);
3111
3177
  if (requester) {
3112
3178
  const result = await requester(input.bbox, input.maxWidth);
@@ -1,8 +1,8 @@
1
- import { B as errMsg, G as createEvent, V as BaseAddon, dt as EventCategory, et as _enum, it as lazy, j as pipelineRunnerCapability, k as nodePin, lt as string, nt as boolean, ot as number, st as object, tt as array, x as defineCustomActions, y as customAction } from "../dist-BzVZ2I4P.mjs";
1
+ import { B as errMsg, G as createEvent, V as BaseAddon, dt as EventCategory, et as _enum, it as lazy, j as pipelineRunnerCapability, k as nodePin, lt as string, nt as boolean, ot as number, st as object, tt as array, x as defineCustomActions, y as customAction } from "../dist-BpWot5-w.mjs";
2
2
  import { n as profileForStreamId, t as isRemoteRestream } from "../remote-restream-BeHi78PZ.mjs";
3
3
  import { r as resolveHubHostname } from "../sheet-geometry-DcpuGTCm.mjs";
4
4
  import { t as isWorkerReply } from "../worker-protocol-CyVJTZEO.mjs";
5
- import { a as getStepDefinition } from "../step-definitions-B07M9qhj.mjs";
5
+ import { a as getStepDefinition } from "../step-definitions-D6R9X0V7.mjs";
6
6
  import { fork } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import sharp from "sharp";
@@ -1242,23 +1242,39 @@ function startGuardedSessionDecode(deps) {
1242
1242
  /** {@link RETAINED_FRAME_BUDGET_MB} in bytes. */
1243
1243
  var RETAINED_FRAME_BUDGET_BYTES = (() => {
1244
1244
  const raw = Number(process.env["CAMSTACK_SESSION_RETAIN_MB"]);
1245
- return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 32;
1245
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 96;
1246
1246
  })() * 1024 * 1024;
1247
+ /**
1248
+ * How many of each device's MOST RECENT frames are protected from a busy
1249
+ * neighbour's budget eviction. Sized to cover the late two-plane / cross-process
1250
+ * enrichment horizon (~150-500 ms): 6 frames ≈ 0.6 s @10 fps of guaranteed
1251
+ * survival for every active camera. Read ONCE from
1252
+ * `CAMSTACK_SESSION_RETAIN_FLOOR`; `0` disables the floor (pure global FIFO — the
1253
+ * pre-2026-07-19 behaviour). The hard byte ceiling always overrides the floor,
1254
+ * so this can never push retention past {@link RETAINED_FRAME_BUDGET_BYTES}.
1255
+ */
1256
+ var RETAINED_FRAME_PER_DEVICE_FLOOR = (() => {
1257
+ const raw = Number(process.env["CAMSTACK_SESSION_RETAIN_FLOOR"]);
1258
+ return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 6;
1259
+ })();
1247
1260
  /** Opaque RAM-key prefix stamped into a retained frame's `FrameHandle.shmId`. */
1248
1261
  var RETAINED_FRAME_KEY_PREFIX = "ram:";
1249
1262
  /** A retention frame is uniquely identified by its key + slot + committed seq. */
1250
1263
  function keyOf(handle) {
1251
1264
  return `${handle.shmId}#${handle.slot}#${handle.seq}`;
1252
1265
  }
1253
- /**
1254
- * Node-level RAM store of retained rgb detection frames, keyed by
1255
- * `FrameHandle` identity. Latest-wins with a byte budget.
1256
- */
1257
1266
  var RetainedFrameStore = class RetainedFrameStore {
1258
1267
  nodeId;
1259
1268
  budgetBytes;
1260
- /** Insertion-ordered (Map) → oldest is `keys().next()`. */
1269
+ perDeviceFloor;
1270
+ /** Insertion-ordered (Map) → globally oldest is `keys().next()`. */
1261
1271
  entries = /* @__PURE__ */ new Map();
1272
+ /**
1273
+ * Per-device insertion-ordered keys (oldest→newest). Mirrors {@link entries}
1274
+ * so the floor can be enforced in O(1) per eviction candidate without scanning
1275
+ * the global map for a device's frame count.
1276
+ */
1277
+ perDeviceKeys = /* @__PURE__ */ new Map();
1262
1278
  bytes = 0;
1263
1279
  seq = 0;
1264
1280
  /** Ring width used only to spread the `slot` field; the map key is authoritative. */
@@ -1266,6 +1282,7 @@ var RetainedFrameStore = class RetainedFrameStore {
1266
1282
  constructor(options) {
1267
1283
  this.nodeId = options.nodeId;
1268
1284
  this.budgetBytes = options.budgetBytes ?? RETAINED_FRAME_BUDGET_BYTES;
1285
+ this.perDeviceFloor = options.perDeviceFloor ?? RETAINED_FRAME_PER_DEVICE_FLOOR;
1269
1286
  }
1270
1287
  /** Number of retained frames (tests / metrics). */
1271
1288
  get size() {
@@ -1298,14 +1315,21 @@ var RetainedFrameStore = class RetainedFrameStore {
1298
1315
  nodeId: this.nodeId,
1299
1316
  slotCount: RetainedFrameStore.SLOT_SPREAD
1300
1317
  };
1301
- this.entries.set(keyOf(handle), frame);
1318
+ const key = keyOf(handle);
1319
+ this.entries.set(key, {
1320
+ frame,
1321
+ deviceId
1322
+ });
1323
+ const deviceKeys = this.perDeviceKeys.get(deviceId);
1324
+ if (deviceKeys) deviceKeys.push(key);
1325
+ else this.perDeviceKeys.set(deviceId, [key]);
1302
1326
  this.bytes += byteLength;
1303
1327
  this.evictToBudget();
1304
1328
  return handle;
1305
1329
  }
1306
1330
  /** The retained frame for `handle`, or `null` if never retained / already evicted. */
1307
1331
  getFrame(handle) {
1308
- return this.entries.get(keyOf(handle)) ?? null;
1332
+ return this.entries.get(keyOf(handle))?.frame ?? null;
1309
1333
  }
1310
1334
  /**
1311
1335
  * Crop (and optionally downscale) the retained rgb frame for `handle` to the
@@ -1316,26 +1340,56 @@ var RetainedFrameStore = class RetainedFrameStore {
1316
1340
  * decode worker's surface (`getNativeCrop`), not here.
1317
1341
  */
1318
1342
  crop(handle, bbox, maxWidth) {
1319
- const frame = this.entries.get(keyOf(handle));
1320
- if (!frame || frame.format !== "rgb") return null;
1321
- return cropRgb(frame, bbox, maxWidth);
1343
+ const entry = this.entries.get(keyOf(handle));
1344
+ if (!entry || entry.frame.format !== "rgb") return null;
1345
+ return cropRgb(entry.frame, bbox, maxWidth);
1322
1346
  }
1323
1347
  /** Drop every retained frame (node/addon shutdown). */
1324
1348
  clear() {
1325
1349
  this.entries.clear();
1350
+ this.perDeviceKeys.clear();
1326
1351
  this.bytes = 0;
1327
1352
  }
1328
- /** Evict oldest frames until the retained bytes fit the budget (keep ≥1). */
1353
+ /**
1354
+ * Evict frames until the retained bytes fit the budget (keep ≥1).
1355
+ *
1356
+ * Pass 1 (fairness): walk globally oldest→newest and drop only frames whose
1357
+ * device still holds MORE than {@link perDeviceFloor} frames — so a busy
1358
+ * camera's excess is reclaimed before any camera's protected recent window.
1359
+ * Pass 2 (OOM guard): if still over budget (every remaining frame is
1360
+ * floor-protected yet the ceiling is exhausted), drop globally oldest-first
1361
+ * regardless of the floor. The ceiling ALWAYS wins over fairness.
1362
+ */
1329
1363
  evictToBudget() {
1330
- while (this.bytes > this.budgetBytes && this.entries.size > 1) {
1331
- const oldest = this.entries.keys().next().value;
1332
- if (oldest === void 0) break;
1333
- const evicted = this.entries.get(oldest);
1334
- this.entries.delete(oldest);
1335
- if (evicted) this.bytes -= evicted.data.byteLength;
1364
+ if (this.bytes <= this.budgetBytes) return;
1365
+ const oldestFirst = [...this.entries.keys()];
1366
+ for (const key of oldestFirst) {
1367
+ if (this.bytes <= this.budgetBytes || this.entries.size <= 1) break;
1368
+ const entry = this.entries.get(key);
1369
+ if (!entry) continue;
1370
+ const deviceKeys = this.perDeviceKeys.get(entry.deviceId);
1371
+ if (deviceKeys && deviceKeys.length > this.perDeviceFloor) this.dropKey(key);
1372
+ }
1373
+ if (this.bytes <= this.budgetBytes) return;
1374
+ for (const key of oldestFirst) {
1375
+ if (this.bytes <= this.budgetBytes || this.entries.size <= 1) break;
1376
+ this.dropKey(key);
1336
1377
  }
1337
1378
  if (this.bytes < 0) this.bytes = 0;
1338
1379
  }
1380
+ /** Remove one frame by key, keeping the byte tally + per-device index exact. */
1381
+ dropKey(key) {
1382
+ const entry = this.entries.get(key);
1383
+ if (!entry) return;
1384
+ this.entries.delete(key);
1385
+ this.bytes -= entry.frame.data.byteLength;
1386
+ const deviceKeys = this.perDeviceKeys.get(entry.deviceId);
1387
+ if (deviceKeys) {
1388
+ const idx = deviceKeys.indexOf(key);
1389
+ if (idx >= 0) deviceKeys.splice(idx, 1);
1390
+ if (deviceKeys.length === 0) this.perDeviceKeys.delete(entry.deviceId);
1391
+ }
1392
+ }
1339
1393
  };
1340
1394
  /**
1341
1395
  * Nearest-neighbour crop + optional downscale of a packed RGB24 frame to a
@@ -1925,6 +1979,18 @@ function startSessionDecodePump(deps) {
1925
1979
  iterator.return().catch(() => {});
1926
1980
  };
1927
1981
  }
1982
+ //#endregion
1983
+ //#region src/session-decode/native-crop-registry.ts
1984
+ /**
1985
+ * Default max entries retained across all active decode sessions on a node. Read
1986
+ * ONCE from `CAMSTACK_NATIVE_CROP_REGISTRY_CAP`; a non-finite / non-positive
1987
+ * value falls back to 256 (was a fixed 64 — too small for a busy multi-camera
1988
+ * node, see class doc).
1989
+ */
1990
+ var DEFAULT_NATIVE_CROP_REGISTRY_CAP = (() => {
1991
+ const raw = Number(process.env["CAMSTACK_NATIVE_CROP_REGISTRY_CAP"]);
1992
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 256;
1993
+ })();
1928
1994
  /** Invoke a requester's best-effort `release()`, swallowing any transport error. */
1929
1995
  function safeRelease(request) {
1930
1996
  try {
@@ -1934,7 +2000,7 @@ function safeRelease(request) {
1934
2000
  var NativeCropRegistry = class {
1935
2001
  capacity;
1936
2002
  entries = /* @__PURE__ */ new Map();
1937
- constructor(capacity = 64) {
2003
+ constructor(capacity = DEFAULT_NATIVE_CROP_REGISTRY_CAP) {
1938
2004
  this.capacity = capacity;
1939
2005
  }
1940
2006
  /** Number of registered handles (tests / metrics). */
@@ -3100,7 +3166,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
3100
3166
  */
3101
3167
  async getNativeCrop(input) {
3102
3168
  const b = input.bbox;
3103
- if (!(b.x <= .001 && b.y <= .001 && b.w >= .999 && b.h >= .999)) {
3169
+ if (!(b.x <= .001 && b.y <= .001 && b.w >= .999 && b.h >= .999 && input.maxWidth === void 0)) {
3104
3170
  const requester = this.nativeCropRegistry.get(input.handle);
3105
3171
  if (requester) {
3106
3172
  const result = await requester(input.bbox, input.maxWidth);
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("../chunk-D6vf50IK.js");
2
- const require_dist = require("../dist-B2jTt7Lq.js");
2
+ const require_dist = require("../dist-D5o9fGxq.js");
3
3
  const require_sheet_geometry = require("../sheet-geometry-BR-ip0CP.js");
4
4
  const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-Dv-oFwjN.js");
5
5
  let node_child_process = require("node:child_process");
@@ -2593,6 +2593,122 @@ function makeBrokerCall(handle) {
2593
2593
  return (fn) => handle.call(fn, BROKER_READY_TIMEOUT_MS);
2594
2594
  }
2595
2595
  //#endregion
2596
+ //#region src/recorder/addon/retention-sweep.ts
2597
+ /**
2598
+ * Periodic footage-retention sweep (recording-spec §6).
2599
+ *
2600
+ * The disk-pressure eviction path only fires when a volume is TOO FULL
2601
+ * (`shouldSaveDiskSpace` + `minFreePercent`); it never enforces a camera's
2602
+ * age/size policy. This sweeper closes that gap: on an hourly cadence it prunes
2603
+ * footage for every device whose operator set an actionable `retention` policy,
2604
+ * delegating the actual selection + delete to the `recording` cap's
2605
+ * `pruneFootage` (which owns the retention FLOOR via `selectEvictions` — this
2606
+ * sweeper only decides WHICH devices to prune and WHEN).
2607
+ *
2608
+ * Owns its own timer + single-flight guard so a slow sweep is never overlapped
2609
+ * by the next tick — the same subsystem-owns-its-timer shape as
2610
+ * `ThumbnailService`. Pure over injected deps (config read + prune + logger +
2611
+ * clock via `setInterval`), so it is unit-testable with fake timers and a mock
2612
+ * `prune`. Every pass is best-effort: a per-device prune failure is logged and
2613
+ * never aborts the rest, and the sweep never throws.
2614
+ */
2615
+ /**
2616
+ * True when the operator set an ACTIONABLE retention policy on a device — at
2617
+ * least one of `maxAgeDays` / `maxSizeGb` is present and positive. Mirrors the
2618
+ * `> 0` guards `selectEvictions` itself applies, so a `0`/absent limit (which
2619
+ * inherits the node default = "keep forever") is treated as NO policy and the
2620
+ * device is skipped. NEVER prune a device without an explicit policy — that
2621
+ * would delete footage the operator never asked to age out.
2622
+ */
2623
+ function hasRetentionPolicy(config) {
2624
+ const retention = config.retention;
2625
+ if (!retention) return false;
2626
+ const byAge = retention.maxAgeDays != null && retention.maxAgeDays > 0;
2627
+ const bySize = retention.maxSizeGb != null && retention.maxSizeGb > 0;
2628
+ return byAge || bySize;
2629
+ }
2630
+ var RetentionSweeper = class {
2631
+ deps;
2632
+ timer = null;
2633
+ /** Single-flight guard: true while a sweep is in flight. */
2634
+ sweeping = false;
2635
+ constructor(deps) {
2636
+ this.deps = deps;
2637
+ }
2638
+ /**
2639
+ * Arm the sweep: run immediately, then on the interval. Idempotent — a second
2640
+ * `start()` while already armed is a no-op. The timer is `unref`'d so it never
2641
+ * keeps the process alive.
2642
+ */
2643
+ start() {
2644
+ if (this.timer !== null) return;
2645
+ const tick = () => {
2646
+ this.sweep();
2647
+ };
2648
+ tick();
2649
+ this.timer = setInterval(tick, this.deps.intervalMs);
2650
+ this.timer.unref?.();
2651
+ }
2652
+ /** Stop the sweep timer. Idempotent. An in-flight sweep still completes. */
2653
+ stop() {
2654
+ if (this.timer !== null) {
2655
+ clearInterval(this.timer);
2656
+ this.timer = null;
2657
+ }
2658
+ }
2659
+ /**
2660
+ * One retention pass. Single-flighted: a call that fires while a previous
2661
+ * sweep is still running is a no-op. Never throws — a config-read failure
2662
+ * aborts THIS pass (logged); a per-device prune failure is logged and the
2663
+ * sweep continues with the remaining devices.
2664
+ */
2665
+ async sweep() {
2666
+ if (this.sweeping) {
2667
+ this.deps.logger.debug("recorder: retention sweep already running — skipping this tick");
2668
+ return;
2669
+ }
2670
+ this.sweeping = true;
2671
+ try {
2672
+ let configs;
2673
+ try {
2674
+ configs = await this.deps.readConfigs();
2675
+ } catch (err) {
2676
+ this.deps.logger.warn("recorder: retention sweep could not read device configs", { meta: { error: require_dist.errMsg(err) } });
2677
+ return;
2678
+ }
2679
+ let devicesSwept = 0;
2680
+ let devicesSkipped = 0;
2681
+ let bucketsPruned = 0;
2682
+ let bytesReclaimed = 0;
2683
+ for (const [deviceId, config] of configs) {
2684
+ if (!hasRetentionPolicy(config)) {
2685
+ devicesSkipped += 1;
2686
+ continue;
2687
+ }
2688
+ try {
2689
+ const result = await this.deps.prune(deviceId);
2690
+ devicesSwept += 1;
2691
+ bucketsPruned += result.deletedBuckets;
2692
+ bytesReclaimed += result.reclaimedBytes;
2693
+ } catch (err) {
2694
+ this.deps.logger.warn("recorder: retention prune failed for device", {
2695
+ tags: { deviceId },
2696
+ meta: { error: require_dist.errMsg(err) }
2697
+ });
2698
+ }
2699
+ }
2700
+ this.deps.logger.info("recorder: retention sweep complete", { meta: {
2701
+ devicesSwept,
2702
+ devicesSkipped,
2703
+ bucketsPruned,
2704
+ bytesReclaimed
2705
+ } });
2706
+ } finally {
2707
+ this.sweeping = false;
2708
+ }
2709
+ }
2710
+ };
2711
+ //#endregion
2596
2712
  //#region src/recorder/addon/recording-hub-host.ts
2597
2713
  /**
2598
2714
  * Resolve the host a REMOTE recording node should use to dial the hub-only
@@ -3599,6 +3715,12 @@ var PLAYBACK_PREFIX = "playback";
3599
3715
  var EXPORTS_PREFIX = "exports";
3600
3716
  /** How often the export janitor sweeps expired exports. */
3601
3717
  var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
3718
+ /** How often the retention sweep prunes footage past each camera's per-device
3719
+ * age/size policy. Retention is NOT time-critical (footage ages out over days,
3720
+ * and the disk-pressure eviction path already bounds the worst case), so an
3721
+ * hourly cadence keeps the storage.list/evict cost negligible while honouring
3722
+ * the recording-spec §6 periodic retention. */
3723
+ var RETENTION_SWEEP_INTERVAL_MS = 60 * 6e4;
3602
3724
  /** Grace after a completed download before a `deleteAfterDownload` file is removed. */
3603
3725
  var EXPORT_DELETE_GRACE_MS = 6e4;
3604
3726
  /** Only `.mp4` export ids matching this shape are servable (traversal guard). */
@@ -3634,6 +3756,12 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3634
3756
  exportEngine = null;
3635
3757
  /** Periodic export-expiry sweep timer. Cleared on shutdown. */
3636
3758
  exportSweepTimer = null;
3759
+ /** The `recording` cap provider — retained so the periodic retention sweep can
3760
+ * call its `pruneFootage`. Null until built in `onInitialize`. */
3761
+ recordingProvider = null;
3762
+ /** Periodic footage-retention sweep (owns its timer + single-flight guard).
3763
+ * Constructed + started in `onHubReachable`, stopped on shutdown. */
3764
+ retentionSweeper = null;
3637
3765
  constructor() {
3638
3766
  super({ ...DEFAULT_CONFIG });
3639
3767
  }
@@ -3742,6 +3870,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3742
3870
  }
3743
3871
  };
3744
3872
  const recordingProvider = buildRecordingProvider(recordingDeps);
3873
+ this.recordingProvider = recordingProvider;
3745
3874
  const evictableProvider = new StorageEvictableProvider({
3746
3875
  index: this.index,
3747
3876
  store: this.segmentStore
@@ -3839,7 +3968,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3839
3968
  clearInterval(this.exportSweepTimer);
3840
3969
  this.exportSweepTimer = null;
3841
3970
  }
3971
+ this.retentionSweeper?.stop();
3972
+ this.retentionSweeper = null;
3842
3973
  this.exportEngine = null;
3974
+ this.recordingProvider = null;
3843
3975
  }
3844
3976
  /**
3845
3977
  * Boot hydrate: once the hub is reachable (`ctx.api.*` safe), resolve the
@@ -3898,6 +4030,14 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
3898
4030
  this.ctx.logger.warn("recorder: thumbnail service start failed", { meta: { error: require_dist.errMsg(err) } });
3899
4031
  }
3900
4032
  await this.bootstrapExports();
4033
+ const provider = this.recordingProvider;
4034
+ if (provider && this.retentionSweeper === null) this.retentionSweeper = new RetentionSweeper({
4035
+ prune: (deviceId) => provider.pruneFootage({ deviceId }),
4036
+ readConfigs: () => readDeviceConfigs(this.configStore()),
4037
+ logger: this.ctx.logger,
4038
+ intervalMs: RETENTION_SWEEP_INTERVAL_MS
4039
+ });
4040
+ this.retentionSweeper?.start();
3901
4041
  }
3902
4042
  /**
3903
4043
  * Resume interrupted exports and arm the expiry sweep. A row left `rendering`