@camstack/addon-pipeline 1.2.215 → 1.2.218

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 (125) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-C2vYSX3O.mjs → default-detection-model-AJyhoTLF.mjs} +1 -1
  4. package/dist/{default-detection-model-f0PImZnh.js → default-detection-model-Bf45EQ98.js} +1 -1
  5. package/dist/detection-pipeline/index.js +5 -5
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-DoHcimdu.js → dist-BbvtiBNc.js} +278 -20
  8. package/dist/{dist-DttqKvsQ.mjs → dist-Dl6GntEh.mjs} +278 -20
  9. package/dist/{lazy-sharp-BeeQGCUu.js → lazy-sharp-L9Lcr62v.js} +1 -1
  10. package/dist/motion-wasm/index.js +2 -2
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-NtM7__8q.mjs → node-CEyw52D2.mjs} +1 -1
  13. package/dist/{node-6uF72wri.js → node-CbCVHEvJ.js} +1 -1
  14. package/dist/pipeline-runner/index.js +546 -25
  15. package/dist/pipeline-runner/index.mjs +545 -24
  16. package/dist/{process-memory-BRdyl5fE.js → process-memory-DBxdiCSF.js} +1 -1
  17. package/dist/{process-memory-CddpCDWL.mjs → process-memory-Du00R6-e.mjs} +1 -1
  18. package/dist/recorder/index.js +238 -52
  19. package/dist/recorder/index.mjs +238 -52
  20. package/dist/{segment-demux-js-CsFtPVzH.mjs → segment-demux-js-B278aBtB.mjs} +1 -1
  21. package/dist/{segment-demux-js-CkJPdyDH.js → segment-demux-js-D7bjzY8M.js} +1 -1
  22. package/dist/session-decode/decode-worker-child.js +58 -45
  23. package/dist/session-decode/decode-worker-child.mjs +57 -44
  24. package/dist/stream-broker/_stub.js +2 -2
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-ZIja2AOe.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BnmbfnAd.mjs} +3 -3
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-yJ2CyixO.mjs +26 -0
  27. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Uxkwkgy5.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DICVQkNi.mjs} +1 -1
  28. package/dist/stream-broker/demux-worker-child.js +1 -1
  29. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  30. package/dist/stream-broker/{hostInit-DpatMS4g.mjs → hostInit-MIKQqs86.mjs} +3 -3
  31. package/dist/stream-broker/index.js +20 -21
  32. package/dist/stream-broker/index.mjs +20 -21
  33. package/dist/stream-broker/remoteEntry.js +1 -1
  34. package/dist/{worker-protocol-BKwkLZWT.mjs → worker-protocol-BEbD5adq.mjs} +1 -1
  35. package/dist/{worker-protocol-BWO2WNjw.js → worker-protocol-C4rh5uiS.js} +1 -1
  36. package/package.json +1 -1
  37. package/python/__pycache__/inference_pool.cpython-312.pyc +0 -0
  38. package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
  39. package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
  40. package/python/__pycache__/test_inference_pool_backpressure.cpython-312-pytest-9.1.1.pyc +0 -0
  41. package/python/__pycache__/test_inference_pool_backpressure.cpython-312.pyc +0 -0
  42. package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
  43. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-312-pytest-9.1.1.pyc +0 -0
  44. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-312.pyc +0 -0
  45. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
  46. package/python/__pycache__/test_inference_pool_deadline.cpython-314.pyc +0 -0
  47. package/python/__pycache__/test_inference_pool_device_selection.cpython-312-pytest-9.1.1.pyc +0 -0
  48. package/python/__pycache__/test_inference_pool_device_selection.cpython-312.pyc +0 -0
  49. package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
  50. package/python/__pycache__/test_inference_pool_layout.cpython-312-pytest-9.1.1.pyc +0 -0
  51. package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
  52. package/python/__pycache__/test_inference_pool_memstats.cpython-312-pytest-9.1.1.pyc +0 -0
  53. package/python/__pycache__/test_inference_pool_memstats.cpython-312.pyc +0 -0
  54. package/python/__pycache__/test_inference_pool_memstats.cpython-314.pyc +0 -0
  55. package/python/__pycache__/test_inference_pool_ov_ppp.cpython-312-pytest-9.1.1.pyc +0 -0
  56. package/python/__pycache__/test_inference_pool_ov_ppp.cpython-312.pyc +0 -0
  57. package/python/__pycache__/test_inference_pool_ov_ppp.cpython-314.pyc +0 -0
  58. package/python/__pycache__/test_inference_pool_preprocess.cpython-312-pytest-9.1.1.pyc +0 -0
  59. package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
  60. package/python/__pycache__/test_inference_pool_reconfigure.cpython-314.pyc +0 -0
  61. package/python/__pycache__/test_inference_pool_static_batch.cpython-312-pytest-9.1.1.pyc +0 -0
  62. package/python/__pycache__/test_inference_pool_static_batch.cpython-312.pyc +0 -0
  63. package/python/__pycache__/test_inference_pool_static_batch.cpython-314.pyc +0 -0
  64. package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
  65. package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
  66. package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
  67. package/python/postprocessors/__pycache__/__init__.cpython-314.pyc +0 -0
  68. package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
  69. package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
  70. package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
  71. package/python/postprocessors/__pycache__/arcface.cpython-314.pyc +0 -0
  72. package/python/postprocessors/__pycache__/clip.cpython-312.pyc +0 -0
  73. package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
  74. package/python/postprocessors/__pycache__/clip.cpython-314.pyc +0 -0
  75. package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
  76. package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
  77. package/python/postprocessors/__pycache__/ctc.cpython-314.pyc +0 -0
  78. package/python/postprocessors/__pycache__/plate_slots.cpython-312.pyc +0 -0
  79. package/python/postprocessors/__pycache__/plate_slots.cpython-314.pyc +0 -0
  80. package/python/postprocessors/__pycache__/rfdetr.cpython-312.pyc +0 -0
  81. package/python/postprocessors/__pycache__/rfdetr.cpython-314.pyc +0 -0
  82. package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
  83. package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
  84. package/python/postprocessors/__pycache__/saliency.cpython-314.pyc +0 -0
  85. package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
  86. package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
  87. package/python/postprocessors/__pycache__/scrfd.cpython-314.pyc +0 -0
  88. package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
  89. package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
  90. package/python/postprocessors/__pycache__/softmax.cpython-314.pyc +0 -0
  91. package/python/postprocessors/__pycache__/ssd.cpython-312.pyc +0 -0
  92. package/python/postprocessors/__pycache__/ssd.cpython-313.pyc +0 -0
  93. package/python/postprocessors/__pycache__/ssd.cpython-314.pyc +0 -0
  94. package/python/postprocessors/__pycache__/test_clip.cpython-312-pytest-9.1.1.pyc +0 -0
  95. package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
  96. package/python/postprocessors/__pycache__/test_ctc.cpython-312-pytest-9.1.1.pyc +0 -0
  97. package/python/postprocessors/__pycache__/test_ctc.cpython-312.pyc +0 -0
  98. package/python/postprocessors/__pycache__/test_ctc.cpython-313-pytest-9.1.1.pyc +0 -0
  99. package/python/postprocessors/__pycache__/test_ctc.cpython-313.pyc +0 -0
  100. package/python/postprocessors/__pycache__/test_ctc.cpython-314.pyc +0 -0
  101. package/python/postprocessors/__pycache__/test_plate_slots.cpython-312-pytest-9.1.1.pyc +0 -0
  102. package/python/postprocessors/__pycache__/test_rfdetr.cpython-312-pytest-9.1.1.pyc +0 -0
  103. package/python/postprocessors/__pycache__/test_rfdetr.cpython-314.pyc +0 -0
  104. package/python/postprocessors/__pycache__/test_saliency.cpython-312-pytest-9.1.1.pyc +0 -0
  105. package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
  106. package/python/postprocessors/__pycache__/test_saliency.cpython-314.pyc +0 -0
  107. package/python/postprocessors/__pycache__/test_softmax.cpython-312-pytest-9.1.1.pyc +0 -0
  108. package/python/postprocessors/__pycache__/test_softmax.cpython-314.pyc +0 -0
  109. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-312-pytest-9.1.1.pyc +0 -0
  110. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-314.pyc +0 -0
  111. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-39.pyc +0 -0
  112. package/python/postprocessors/__pycache__/test_yolonas.cpython-312-pytest-9.1.1.pyc +0 -0
  113. package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
  114. package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
  115. package/python/postprocessors/__pycache__/yamnet.cpython-314.pyc +0 -0
  116. package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
  117. package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
  118. package/python/postprocessors/__pycache__/yolo.cpython-314.pyc +0 -0
  119. package/python/postprocessors/__pycache__/yolo.cpython-39.pyc +0 -0
  120. package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
  121. package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
  122. package/python/postprocessors/__pycache__/yolo_seg.cpython-314.pyc +0 -0
  123. package/python/postprocessors/__pycache__/yolonas.cpython-312.pyc +0 -0
  124. package/python/postprocessors/__pycache__/yolonas.cpython-314.pyc +0 -0
  125. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-0MF-KL1v.mjs +0 -26
@@ -1,7 +1,7 @@
1
- import { $ as failureContributionCapability, It as errMsg, Qt as nodePin, R as customAction, U as deriveDetailCropRect, Ut as BaseAddon, V as defineCustomActions, _ as FailureCounters, _t as pickDetailCropConvention, an as boolean, bt as pipelineRunnerCapability, cn as literal, hn as EventCategory, in as array, it as loadContributionCapability, ln as number, m as DEVICE_BACKEND_TO_FORMAT, p as DEFAULT_NATIVE_LEASE_SETTINGS, pn as string, qt as createEvent, rn as _enum, sn as lazy, u as DEFAULT_DETAIL_CROP_CONVENTION, un as object, vt as pickNativeLeaseOverride } from "../dist-DttqKvsQ.mjs";
2
- import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-NtM7__8q.mjs";
3
- import { i as resolveFrameViewGeometry, l as getStepDefinition, r as localFrameRegistry } from "../default-detection-model-C2vYSX3O.mjs";
4
- import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-BKwkLZWT.mjs";
1
+ import { $ as failureContributionCapability, It as errMsg, Qt as nodePin, R as customAction, U as deriveDetailCropRect, Ut as BaseAddon, V as defineCustomActions, _ as FailureCounters, _t as pickDetailCropConvention, an as boolean, bt as pipelineRunnerCapability, cn as literal, hn as EventCategory, in as array, it as loadContributionCapability, ln as number, m as DEVICE_BACKEND_TO_FORMAT, p as DEFAULT_NATIVE_LEASE_SETTINGS, pn as string, qt as createEvent, rn as _enum, sn as lazy, u as DEFAULT_DETAIL_CROP_CONVENTION, un as object, vt as pickNativeLeaseOverride } from "../dist-Dl6GntEh.mjs";
2
+ import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-CEyw52D2.mjs";
3
+ import { i as resolveFrameViewGeometry, l as getStepDefinition, r as localFrameRegistry } from "../default-detection-model-AJyhoTLF.mjs";
4
+ import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-BEbD5adq.mjs";
5
5
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
6
6
  import { t as getSharp } from "../lazy-sharp-DXsqwpph.mjs";
7
7
  import { a as preRollRequestOf, o as withDetectionIntent, t as DETECTION_PRE_ROLL_REQUEST_MS } from "../restream-intent-DWe2kInY.mjs";
@@ -380,6 +380,84 @@ function positiveOrUndefined(value) {
380
380
  return Number.isFinite(value) && value > 0 ? value : void 0;
381
381
  }
382
382
  //#endregion
383
+ //#region src/session-decode/native-crop-lease-age.ts
384
+ /**
385
+ * THE age bound on a native crop lease (ADR-0419).
386
+ *
387
+ * ## The measurement
388
+ *
389
+ * Mac agent node (`MinidiGianluca.localdomain`), 2026-09-09, a 54-minute
390
+ * window: 332 `native crop miss` lines, every one of them `worker-lease-gone`,
391
+ * at a MEDIAN `handleAgeMs` of 30 099 ms — thirty seconds. The node-wide
392
+ * {@link ../session-decode/native-crop-registry.NativeCropRegistry} sat pinned
393
+ * at `registrySize 4096 / registryCap 4096` for the whole window. Measured
394
+ * consequence: on track `d64ace88` (device 1443) the frontal-face frames never
395
+ * reached the face detector, because every native fetch missed.
396
+ *
397
+ * ## Why an AGE bound and not a bigger cap
398
+ *
399
+ * The pinned cap is what the miss line shows, so raising it is the instinctive
400
+ * move, and it is wrong twice. It keeps more full-resolution surfaces alive
401
+ * (real RAM, on a ring that has leaked a VAAPI surface pool before), and it
402
+ * only moves the threshold at which the same pinning returns. It also cannot
403
+ * work: the decode worker's own tile store expires a frame's tiles at
404
+ * `NATIVE_TILE_TTL_MS` (30 s), so a handle 30 s old has nothing behind it at
405
+ * ANY registry size. The operator's decision, 2026-09-09: **a native lease
406
+ * lives at most ten seconds**; a crop asked for later than that cannot be
407
+ * useful, and the caller's detection-frame fallback — which
408
+ * `native-frame-ring.ts` calls FREE and expected — is the right answer.
409
+ *
410
+ * ## Why the bound is enforced at the registry
411
+ *
412
+ * The registry is the only place in the system where a handle's registration
413
+ * wall-clock exists: `FrameHandle` carries no correlatable clock (`pts` is
414
+ * explicitly process-local and must never be compared against another), so
415
+ * neither the hub-side requester nor the decode worker can decide this
416
+ * question correctly. It is also the single node-wide chokepoint every native
417
+ * path funnels through — the `getNativeCrop` cap, `getNativeCropForDetail`,
418
+ * and `runDetailSubtree`'s rung 1 — so ONE rule covers all of them, and it
419
+ * sits in-process, in FRONT of the round trip that the 332 misses paid for.
420
+ * Refusing here makes the wasted work impossible rather than merely faster.
421
+ *
422
+ * Deliberately a constant and not an operator knob: it is a property of the
423
+ * pipeline, not of a node's hardware (same argument as the lease knobs in
424
+ * `@camstack/types` `pipeline/native-lease.ts`), and a per-node value would
425
+ * make the same camera produce different crop quality depending on where the
426
+ * balancer placed it.
427
+ */
428
+ /**
429
+ * How long a registered handle may be asked for a native crop. Inclusive: an
430
+ * ask at exactly this age is still served, one millisecond later is refused.
431
+ *
432
+ * ## Ten seconds was tried on the fleet, and the fleet refused it
433
+ *
434
+ * The operator's number was ten seconds, chosen on the belief — stated in this
435
+ * file's first version, and sourced from `@camstack/types`
436
+ * `pipeline/native-lease.ts` sizing prose rather than from a measurement of
437
+ * THIS path — that a p90 ask lands at 4.3 s and a p50 at 367 ms. Deployed
438
+ * 2026-09-09 ~14:00, it produced, on the hub, **3 371 `no-registry-entry`
439
+ * misses in fifteen minutes** (2 036 of them on `salone` alone) against
440
+ * essentially ZERO in the half hour before the deploy: the registry held 46
441
+ * entries where it had held 4 096, and the asks arriving between 10 s and 30 s
442
+ * found nothing at all. The operator saw it as tracks with missing media.
443
+ *
444
+ * So the bound is the worker's own tile TTL (`NATIVE_TILE_TTL_MS`, 30 s,
445
+ * `decode-worker-child.ts`) — the age past which there is provably nothing
446
+ * behind the handle at ANY registry size. It still bounds the lease by AGE
447
+ * rather than by a 4 096-entry capacity eviction, which is what D419 set out
448
+ * to fix; it simply stops refusing asks the worker could still have answered.
449
+ *
450
+ * The real defect is caller-side and is not fixed here: a ~1 Hz
451
+ * `TrackDetailDispatcher` re-asks a frozen handle for the life of a coasted
452
+ * track, and `persistKeyFrames` / `offerGroupShot` are the only capture sites
453
+ * that omit `staleAfterMs`. ADR-0419 named both as open; until they are fixed
454
+ * a tighter bound cannot be told apart from a broken one.
455
+ */
456
+ var NATIVE_CROP_LEASE_MAX_AGE_MS = 3e4;
457
+ function classifyNativeCropLeaseAge(ageMs, maxAgeMs = NATIVE_CROP_LEASE_MAX_AGE_MS) {
458
+ return ageMs > maxAgeMs ? "expired" : "live";
459
+ }
460
+ //#endregion
383
461
  //#region src/session-decode/native-crop-registry.ts
384
462
  var DEFAULT_NATIVE_CROP_REGISTRY_CAP = (() => {
385
463
  const raw = Number(process.env["CAMSTACK_NATIVE_CROP_REGISTRY_CAP"]);
@@ -393,9 +471,11 @@ function safeRelease(request) {
393
471
  }
394
472
  var NativeCropRegistry = class {
395
473
  capacity;
474
+ maxAgeMs;
396
475
  entries = /* @__PURE__ */ new Map();
397
- constructor(capacity = DEFAULT_NATIVE_CROP_REGISTRY_CAP) {
476
+ constructor(capacity = DEFAULT_NATIVE_CROP_REGISTRY_CAP, maxAgeMs = NATIVE_CROP_LEASE_MAX_AGE_MS) {
398
477
  this.capacity = capacity;
478
+ this.maxAgeMs = maxAgeMs;
399
479
  }
400
480
  /** Number of registered handles (tests / metrics). */
401
481
  get size() {
@@ -410,6 +490,7 @@ var NativeCropRegistry = class {
410
490
  * its worker-side RAM lease so a lease never outlives its registry entry.
411
491
  */
412
492
  register(handle, request, deviceId) {
493
+ this.sweepExpired();
413
494
  const k = this.key(handle);
414
495
  this.entries.delete(k);
415
496
  this.entries.set(k, {
@@ -420,27 +501,76 @@ var NativeCropRegistry = class {
420
501
  while (this.entries.size > this.capacity) {
421
502
  const oldest = this.entries.keys().next().value;
422
503
  if (oldest === void 0) break;
423
- this.releaseEntry(oldest);
504
+ this.releaseEntry(oldest, "capacity");
424
505
  }
425
506
  }
426
- /** The requester for `handle`, or `null` if never registered / already evicted. */
427
- get(handle) {
428
- const entry = this.entries.get(this.key(handle));
429
- if (entry === void 0) return null;
507
+ /**
508
+ * Resolve `handle`, applying the age bound (ADR-0419).
509
+ *
510
+ * The bound is applied HERE and not at the caller or the worker because this
511
+ * is the only place `registeredAt` exists — see `native-crop-lease-age.ts`.
512
+ * An expired handle is refused and released without the requester ever being
513
+ * invoked, so the doomed cross-process fetch does not happen at all.
514
+ */
515
+ lookup(handle) {
516
+ const key = this.key(handle);
517
+ const entry = this.entries.get(key);
518
+ if (entry === void 0) {
519
+ const swept = this.sweptByAge.get(key);
520
+ if (swept === void 0) return { kind: "unknown" };
521
+ return {
522
+ kind: "expired",
523
+ deviceId: swept.deviceId,
524
+ ageMs: Date.now() - swept.registeredAt
525
+ };
526
+ }
527
+ const ageMs = Date.now() - entry.registeredAt;
528
+ if (classifyNativeCropLeaseAge(ageMs, this.maxAgeMs) === "expired") {
529
+ this.releaseEntry(key, "age");
530
+ return {
531
+ kind: "expired",
532
+ deviceId: entry.deviceId,
533
+ ageMs
534
+ };
535
+ }
430
536
  return {
537
+ kind: "live",
431
538
  request: entry.request,
432
539
  deviceId: entry.deviceId,
433
- ageMs: Date.now() - entry.registeredAt
540
+ ageMs
434
541
  };
435
542
  }
436
543
  /**
544
+ * Drop every entry past the age bound, oldest first.
545
+ *
546
+ * The map is insertion-ordered and insertion order IS registration order, so
547
+ * the head is the oldest and the walk stops at the first live entry. Run on
548
+ * every `register`, which is the only path that reliably ticks on a busy
549
+ * node — a timer here would be a second owner of the same lifetime.
550
+ *
551
+ * It can never drop an entry a request would have been served from: anything
552
+ * it releases would have been refused by {@link lookup} in the same instant.
553
+ * What it buys is that the worker's lease is released when it stops being
554
+ * useful (10 s) instead of when 4 095 later handles have pushed it out of a
555
+ * capacity eviction (~30 s on this cluster) — the reason the registry sat
556
+ * pinned at `4096/4096`.
557
+ */
558
+ sweepExpired() {
559
+ const now = Date.now();
560
+ for (const [key, entry] of this.entries) {
561
+ if (classifyNativeCropLeaseAge(now - entry.registeredAt, this.maxAgeMs) === "live") return;
562
+ this.releaseEntry(key, "age");
563
+ }
564
+ }
565
+ /**
437
566
  * The deviceId a handle was registered under, even AFTER its entry was
438
567
  * evicted — kept in a small parallel ring so a `no-registry-entry` miss can
439
568
  * still be attributed to a camera (a miss line without `deviceId` cannot
440
569
  * answer "why is 617 worse than 615?").
441
570
  */
442
571
  deviceIdFor(handle) {
443
- return this.evictedDeviceIds.get(this.key(handle)) ?? this.entries.get(this.key(handle))?.deviceId ?? null;
572
+ const key = this.key(handle);
573
+ return this.evictedDeviceIds.get(key) ?? this.sweptByAge.get(key)?.deviceId ?? this.entries.get(key)?.deviceId ?? null;
444
574
  }
445
575
  /**
446
576
  * Release the worker-side RAM native lease for `handle` and drop its entry —
@@ -455,13 +585,24 @@ var NativeCropRegistry = class {
455
585
  for (const entry of this.entries.values()) safeRelease(entry.request);
456
586
  this.entries.clear();
457
587
  this.evictedDeviceIds.clear();
588
+ this.sweptByAge.clear();
458
589
  }
459
- /** Release + drop one entry by its map key, remembering its camera. */
460
- releaseEntry(key) {
590
+ /**
591
+ * Release + drop one entry by its map key, remembering WHY it left.
592
+ *
593
+ * `age` — past the lease bound (a `lookup` refusal or a `register` sweep):
594
+ * remembered with its registration clock, so a later ask for the same
595
+ * handle is still answered `expired` with a real age. `capacity` — the cap
596
+ * bound and pushed it out; `released` — the caller was done with it. Those
597
+ * two stay `unknown` on a later ask: neither is a late ask, and folding
598
+ * them into the refusal count would make the bound look tighter than it is.
599
+ */
600
+ releaseEntry(key, cause = "released") {
461
601
  const entry = this.entries.get(key);
462
602
  if (!entry) return;
463
603
  this.entries.delete(key);
464
- this.rememberEvicted(key, entry.deviceId);
604
+ if (cause === "age") this.rememberSweptByAge(key, entry);
605
+ else this.rememberEvicted(key, entry.deviceId);
465
606
  safeRelease(entry.request);
466
607
  }
467
608
  /** Bounded memory of evicted handles → deviceId, for miss attribution. */
@@ -474,6 +615,24 @@ var NativeCropRegistry = class {
474
615
  this.evictedDeviceIds.delete(oldest);
475
616
  }
476
617
  }
618
+ /**
619
+ * Bounded memory of handles the AGE bound dropped, with the clock the
620
+ * refusal is measured from. Same capacity as the live map: at the busiest
621
+ * node's rate that is one more lease window of memory, after which a
622
+ * refusal degrades to `unknown` — an ask that late is not a bound question.
623
+ */
624
+ sweptByAge = /* @__PURE__ */ new Map();
625
+ rememberSweptByAge(key, entry) {
626
+ this.sweptByAge.set(key, {
627
+ deviceId: entry.deviceId,
628
+ registeredAt: entry.registeredAt
629
+ });
630
+ while (this.sweptByAge.size > this.capacity) {
631
+ const oldest = this.sweptByAge.keys().next().value;
632
+ if (oldest === void 0) break;
633
+ this.sweptByAge.delete(oldest);
634
+ }
635
+ }
477
636
  };
478
637
  //#endregion
479
638
  //#region src/session-decode/node-decode-budget.ts
@@ -568,6 +727,160 @@ function createRetainedContentFallback(registry, ref) {
568
727
  }
569
728
  } };
570
729
  }
730
+ //#endregion
731
+ //#region src/session-decode/parked-frame-store.ts
732
+ /** Per-camera parcel bound. 24 live tracks × two kinds on one camera is a
733
+ * crowd scene; past that the camera's oldest parcel leaves first. Read ONCE
734
+ * from `CAMSTACK_PARKED_FRAMES_PER_DEVICE`. */
735
+ var PARKED_FRAMES_PER_DEVICE_MAX = (() => {
736
+ const raw = Number(process.env["CAMSTACK_PARKED_FRAMES_PER_DEVICE"]);
737
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 48;
738
+ })();
739
+ /** {@link PARKED_FRAME_BUDGET_MB} in bytes. */
740
+ var PARKED_FRAME_BUDGET_BYTES = (() => {
741
+ const raw = Number(process.env["CAMSTACK_PARKED_FRAME_MB"]);
742
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 64;
743
+ })() * 1024 * 1024;
744
+ function keyOf$1(deviceId, trackId, kind) {
745
+ return `${deviceId}|${trackId}|${kind}`;
746
+ }
747
+ var ParkedFrameStore = class {
748
+ budgetBytes;
749
+ perDeviceMax;
750
+ maxAgeMs;
751
+ now;
752
+ onEvict;
753
+ /** Insertion-ordered → the head is the globally oldest park. A replace
754
+ * re-inserts, so order IS park order. */
755
+ entries = /* @__PURE__ */ new Map();
756
+ /** Per-device keys in park order, mirroring {@link entries} for the bound. */
757
+ perDeviceKeys = /* @__PURE__ */ new Map();
758
+ totalBytes = 0;
759
+ constructor(options = {}) {
760
+ this.budgetBytes = options.budgetBytes ?? PARKED_FRAME_BUDGET_BYTES;
761
+ this.perDeviceMax = options.perDeviceMax ?? PARKED_FRAMES_PER_DEVICE_MAX;
762
+ this.maxAgeMs = options.maxAgeMs ?? 6e5;
763
+ this.now = options.now ?? Date.now;
764
+ this.onEvict = options.onEvict;
765
+ }
766
+ /** Number of parcels held (tests / metrics). */
767
+ get size() {
768
+ return this.entries.size;
769
+ }
770
+ /** Bytes of JPEG held (tests / metrics). */
771
+ get bytes() {
772
+ return this.totalBytes;
773
+ }
774
+ /**
775
+ * Park one parcel. Replaces an existing parcel for the key unless the new
776
+ * one shows an OLDER frame instant (a racing loser). Sweeps the age ceiling,
777
+ * then enforces the per-device bound and the node budget — evicting the
778
+ * oldest of the offending scope, never refusing the new parcel.
779
+ */
780
+ park(deviceId, trackId, kind, frame) {
781
+ const nowMs = this.now();
782
+ this.sweepExpired(nowMs);
783
+ const key = keyOf$1(deviceId, trackId, kind);
784
+ const existing = this.entries.get(key);
785
+ if (existing !== void 0 && frame.timestamp < existing.frame.timestamp) return "older-ignored";
786
+ if (existing !== void 0) this.remove(key);
787
+ this.entries.set(key, {
788
+ deviceId,
789
+ trackId,
790
+ kind,
791
+ frame,
792
+ parkedAt: nowMs
793
+ });
794
+ const deviceKeys = this.perDeviceKeys.get(deviceId);
795
+ if (deviceKeys) deviceKeys.push(key);
796
+ else this.perDeviceKeys.set(deviceId, [key]);
797
+ this.totalBytes += frame.jpeg.byteLength;
798
+ this.enforceDeviceBound(deviceId);
799
+ this.enforceBudget();
800
+ return existing === void 0 ? "stored" : "replaced";
801
+ }
802
+ /** The parcel for a key, or `null` — never anything else, never by handle. */
803
+ get(deviceId, trackId, kind) {
804
+ const entry = this.entries.get(keyOf$1(deviceId, trackId, kind));
805
+ if (entry === void 0) return null;
806
+ if (this.now() - entry.parkedAt > this.maxAgeMs) {
807
+ this.evict(keyOf$1(deviceId, trackId, kind), "age");
808
+ return null;
809
+ }
810
+ return {
811
+ ...entry.frame,
812
+ parkedAt: entry.parkedAt
813
+ };
814
+ }
815
+ /** Drop every parcel of a track (the hub closed it). Returns how many left. */
816
+ release(deviceId, trackId) {
817
+ const keys = (this.perDeviceKeys.get(deviceId) ?? []).filter((key) => this.entries.get(key)?.trackId === trackId);
818
+ for (const key of keys) this.remove(key);
819
+ return keys.length;
820
+ }
821
+ /** What one camera is costing right now. */
822
+ statsByDevice(deviceId) {
823
+ const keys = this.perDeviceKeys.get(deviceId) ?? [];
824
+ let bytes = 0;
825
+ for (const key of keys) bytes += this.entries.get(key)?.frame.jpeg.byteLength ?? 0;
826
+ return {
827
+ parcels: keys.length,
828
+ bytes
829
+ };
830
+ }
831
+ /** Drop everything (addon shutdown). */
832
+ clear() {
833
+ this.entries.clear();
834
+ this.perDeviceKeys.clear();
835
+ this.totalBytes = 0;
836
+ }
837
+ /** Age sweep from the head — insertion order is park order, so it stops at
838
+ * the first parcel still inside the ceiling. */
839
+ sweepExpired(nowMs) {
840
+ for (const [key, entry] of this.entries) {
841
+ if (nowMs - entry.parkedAt <= this.maxAgeMs) return;
842
+ this.evict(key, "age");
843
+ }
844
+ }
845
+ enforceDeviceBound(deviceId) {
846
+ const deviceKeys = this.perDeviceKeys.get(deviceId);
847
+ while (deviceKeys !== void 0 && deviceKeys.length > this.perDeviceMax) {
848
+ const oldest = deviceKeys[0];
849
+ if (oldest === void 0) break;
850
+ this.evict(oldest, "device-bound");
851
+ }
852
+ }
853
+ /** Globally oldest first, whatever its device; always keeps ≥ 1. */
854
+ enforceBudget() {
855
+ for (const key of [...this.entries.keys()]) {
856
+ if (this.totalBytes <= this.budgetBytes || this.entries.size <= 1) return;
857
+ this.evict(key, "node-budget");
858
+ }
859
+ }
860
+ evict(key, cause) {
861
+ const entry = this.entries.get(key);
862
+ if (entry === void 0) return;
863
+ this.remove(key);
864
+ this.onEvict?.({
865
+ deviceId: entry.deviceId,
866
+ trackId: entry.trackId,
867
+ kind: entry.kind,
868
+ cause
869
+ });
870
+ }
871
+ remove(key) {
872
+ const entry = this.entries.get(key);
873
+ if (entry === void 0) return;
874
+ this.entries.delete(key);
875
+ this.totalBytes -= entry.frame.jpeg.byteLength;
876
+ const deviceKeys = this.perDeviceKeys.get(entry.deviceId);
877
+ if (deviceKeys) {
878
+ const idx = deviceKeys.indexOf(key);
879
+ if (idx >= 0) deviceKeys.splice(idx, 1);
880
+ if (deviceKeys.length === 0) this.perDeviceKeys.delete(entry.deviceId);
881
+ }
882
+ }
883
+ };
571
884
  /** {@link RETAINED_FRAME_BUDGET_MB} in bytes. */
572
885
  var RETAINED_FRAME_BUDGET_BYTES = (() => {
573
886
  const raw = Number(process.env["CAMSTACK_SESSION_RETAIN_MB"]);
@@ -6031,6 +6344,90 @@ function isUsable(bbox) {
6031
6344
  return bbox.width > 0 && bbox.height > 0;
6032
6345
  }
6033
6346
  //#endregion
6347
+ //#region src/pipeline-runner/track-frame-park.ts
6348
+ /** Park one frame for one track. Never throws; a refusal is an answer. */
6349
+ async function parkTrackFrame(deps, input) {
6350
+ const { deviceId, trackId, kind } = input;
6351
+ const handleKey = `${input.handle.shmId}#${input.handle.slot}#${input.handle.seq}`;
6352
+ const resolved = await deps.resolvePixels({
6353
+ handle: input.handle,
6354
+ bbox: input.bbox,
6355
+ ...input.maxWidth !== void 0 ? { maxWidth: input.maxWidth } : {}
6356
+ });
6357
+ if (resolved.pixels === null || resolved.tier !== "native") {
6358
+ const missReason = resolved.missReason ?? "worker-lease-gone";
6359
+ const detail = resolved.workerReason !== void 0 ? `${missReason}: ${resolved.workerReason}` : missReason;
6360
+ deps.logger.warn("track frame park refused — the handle no longer resolves at park time", {
6361
+ tags: { deviceId },
6362
+ meta: {
6363
+ trackId,
6364
+ kind,
6365
+ reason: "handle-gone",
6366
+ missReason,
6367
+ handleAgeMs: resolved.handleAgeMs,
6368
+ servedTier: resolved.tier,
6369
+ handle: handleKey,
6370
+ ...resolved.workerReason !== void 0 ? { workerReason: resolved.workerReason } : {}
6371
+ }
6372
+ });
6373
+ return {
6374
+ parked: false,
6375
+ nodeId: deps.nodeId,
6376
+ reason: "handle-gone",
6377
+ handleAgeMs: resolved.handleAgeMs,
6378
+ detail
6379
+ };
6380
+ }
6381
+ const jpeg = await deps.encodeJpeg(resolved.pixels);
6382
+ if (jpeg === null) {
6383
+ deps.logger.warn("track frame park refused — JPEG encode failed", {
6384
+ tags: { deviceId },
6385
+ meta: {
6386
+ trackId,
6387
+ kind,
6388
+ reason: "encode-failed",
6389
+ handleAgeMs: resolved.handleAgeMs,
6390
+ handle: handleKey,
6391
+ width: resolved.pixels.width,
6392
+ height: resolved.pixels.height
6393
+ }
6394
+ });
6395
+ return {
6396
+ parked: false,
6397
+ nodeId: deps.nodeId,
6398
+ reason: "encode-failed",
6399
+ handleAgeMs: resolved.handleAgeMs
6400
+ };
6401
+ }
6402
+ const verdict = deps.store.park(deviceId, trackId, kind, {
6403
+ jpeg,
6404
+ width: resolved.pixels.width,
6405
+ height: resolved.pixels.height,
6406
+ timestamp: input.timestamp
6407
+ });
6408
+ deps.logger.debug("track frame parked", {
6409
+ tags: { deviceId },
6410
+ meta: {
6411
+ trackId,
6412
+ kind,
6413
+ verdict,
6414
+ bytes: jpeg.byteLength,
6415
+ width: resolved.pixels.width,
6416
+ height: resolved.pixels.height,
6417
+ handleAgeMs: resolved.handleAgeMs,
6418
+ source: resolved.pixels.source ?? "lease"
6419
+ }
6420
+ });
6421
+ return {
6422
+ parked: true,
6423
+ nodeId: deps.nodeId,
6424
+ width: resolved.pixels.width,
6425
+ height: resolved.pixels.height,
6426
+ bytes: jpeg.byteLength,
6427
+ replaced: verdict === "replaced"
6428
+ };
6429
+ }
6430
+ //#endregion
6034
6431
  //#region src/pipeline-runner/index.ts
6035
6432
  /**
6036
6433
  * addon-pipeline-runner — per-node detection scheduler.
@@ -6100,6 +6497,8 @@ var PACKAGE_ZONE_UNION_TTL_MS = 6e4;
6100
6497
  var MAX_BOUND_FRAME_CUTTERS = 256;
6101
6498
  /** How often a camera reports its undispatched cuts. */
6102
6499
  var TILE_CUT_DROP_LOG_INTERVAL_MS = 6e4;
6500
+ /** How often a camera reports parcels that left without a release (D422). */
6501
+ var PARKED_EVICTION_LOG_INTERVAL_MS = 6e4;
6103
6502
  /**
6104
6503
  * How often a camera reports what carrying its birth evidence COST (D379).
6105
6504
  *
@@ -6129,7 +6528,6 @@ var DEFAULT_CONFIG = {
6129
6528
  * this window REUSES the warm worker instead of re-forking it — killing the
6130
6529
  * spawn/teardown churn (and its cold-start latency spike) that on-motion
6131
6530
  * cameras produced when every gate open/close forked and killed a worker.
6132
- * Mirrors Scrypted's decode-fork ~15s idle-timeout.
6133
6531
  */
6134
6532
  /** JPEG quality for the runner→executor detection-frame hop. q82 keeps
6135
6533
  * detector-relevant detail (source is h264 anyway) at ~10-15× less payload
@@ -6487,6 +6885,17 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6487
6885
  * Initialised in {@link onInitialize} once `nodeId` is known.
6488
6886
  */
6489
6887
  retainedFrames = null;
6888
+ /**
6889
+ * Per-track PARCELS (D422): one JPEG per `(deviceId, trackId, kind)`, cut
6890
+ * from the native surface at the instant the hub judged the frame relevant,
6891
+ * retrieved later by that key — never by handle, never by age. Bounded per
6892
+ * device and per node in `parked-frame-store.ts`, where the numbers are
6893
+ * stated. Initialised in {@link onInitialize} beside the retained store.
6894
+ */
6895
+ parkedFrames = null;
6896
+ /** Per-camera tally of parcels that left WITHOUT a release, by cause. */
6897
+ parkedEvictions = /* @__PURE__ */ new Map();
6898
+ parkedEvictionLoggedAt = /* @__PURE__ */ new Map();
6490
6899
  frameLazy = new FrameLazyTelemetry();
6491
6900
  retention = null;
6492
6901
  attached = /* @__PURE__ */ new Map();
@@ -6717,6 +7126,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6717
7126
  nodeId: this.nodeId,
6718
7127
  continuousRetain: isContinuousRetainEnabled()
6719
7128
  });
7129
+ this.parkedFrames = new ParkedFrameStore({ onEvict: (eviction) => this.noteParkedEviction(eviction.deviceId, eviction.cause) });
6720
7130
  this.runner = new PipelineRunner({
6721
7131
  maxQueueDepth: this.config.maxQueueDepth,
6722
7132
  maxConcurrentInferences: this.config.maxConcurrentInferences,
@@ -6868,6 +7278,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6868
7278
  this.nativeCropRegistry.clear();
6869
7279
  this.retainedFrames?.clear();
6870
7280
  this.retainedFrames = null;
7281
+ this.parkedFrames?.clear();
7282
+ this.parkedFrames = null;
6871
7283
  this.retention = null;
6872
7284
  }
6873
7285
  async cacheBenchFrame(input) {
@@ -7662,6 +8074,96 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
7662
8074
  }, resolved.tier, input.encodeJpeg);
7663
8075
  }
7664
8076
  /**
8077
+ * PARK a frame for a track (D422) — the hub judged it relevant, and this node
8078
+ * still holds its pixels. Same native-lease resolve `getNativeCrop` uses,
8079
+ * same single encoder; the parcel lives in {@link parkedFrames} under
8080
+ * `(deviceId, trackId, kind)`. A refusal is an answer and a `deviceId`-tagged
8081
+ * log line, never a retry.
8082
+ */
8083
+ async parkTrackFrame(input) {
8084
+ const store = this.parkedFrames;
8085
+ if (store === null) return {
8086
+ parked: false,
8087
+ nodeId: this.nodeId,
8088
+ reason: "handle-gone",
8089
+ handleAgeMs: null,
8090
+ detail: "runner not initialised"
8091
+ };
8092
+ return parkTrackFrame({
8093
+ nodeId: this.nodeId,
8094
+ store,
8095
+ resolvePixels: (request) => this.resolveNativeCropPixels(request),
8096
+ encodeJpeg: async (pixels) => {
8097
+ const encoded = await this.encodeNativeCropResult(pixels, "native", true);
8098
+ return encoded?.jpeg !== void 0 ? Buffer.from(encoded.jpeg, "base64") : null;
8099
+ },
8100
+ logger: this.ctx.logger
8101
+ }, input);
8102
+ }
8103
+ /** A parcel by `(deviceId, trackId, kind)`; `null` = nothing parked here. */
8104
+ async getParkedTrackFrame(input) {
8105
+ const parcel = this.parkedFrames?.get(input.deviceId, input.trackId, input.kind) ?? null;
8106
+ if (parcel === null) {
8107
+ this.ctxIfReady?.logger.debug("parked frame miss — nothing under this key", {
8108
+ tags: { deviceId: input.deviceId },
8109
+ meta: {
8110
+ trackId: input.trackId,
8111
+ kind: input.kind,
8112
+ ...this.parkedFrames?.statsByDevice(input.deviceId) ?? {
8113
+ parcels: 0,
8114
+ bytes: 0
8115
+ }
8116
+ }
8117
+ });
8118
+ return null;
8119
+ }
8120
+ return {
8121
+ jpeg: parcel.jpeg.toString("base64"),
8122
+ width: parcel.width,
8123
+ height: parcel.height,
8124
+ timestamp: parcel.timestamp,
8125
+ parkedAt: parcel.parkedAt
8126
+ };
8127
+ }
8128
+ /** The hub closed a track: drop its parcels. Idempotent. */
8129
+ async releaseParkedTrackFrames(input) {
8130
+ return { released: this.parkedFrames?.release(input.deviceId, input.trackId) ?? 0 };
8131
+ }
8132
+ /**
8133
+ * A parcel left without a release. Counted per camera by cause and reported
8134
+ * one line per camera per minute: a camera whose parcels keep leaving under
8135
+ * the device bound is a crowd, under the node budget is a neighbour's
8136
+ * traffic, and under the age ceiling is a hub that stopped releasing.
8137
+ */
8138
+ noteParkedEviction(deviceId, cause) {
8139
+ const tally = this.parkedEvictions.get(deviceId) ?? {
8140
+ deviceBound: 0,
8141
+ nodeBudget: 0,
8142
+ age: 0
8143
+ };
8144
+ if (cause === "device-bound") tally.deviceBound += 1;
8145
+ else if (cause === "node-budget") tally.nodeBudget += 1;
8146
+ else tally.age += 1;
8147
+ this.parkedEvictions.set(deviceId, tally);
8148
+ const now = Date.now();
8149
+ if (now - (this.parkedEvictionLoggedAt.get(deviceId) ?? 0) < PARKED_EVICTION_LOG_INTERVAL_MS) return;
8150
+ this.parkedEvictionLoggedAt.set(deviceId, now);
8151
+ this.ctxIfReady?.logger.info("parked frames evicted without a release", {
8152
+ tags: { deviceId },
8153
+ meta: {
8154
+ ...tally,
8155
+ lastCause: cause,
8156
+ parcelsHeld: this.parkedFrames?.size ?? 0,
8157
+ bytesHeld: this.parkedFrames?.bytes ?? 0
8158
+ }
8159
+ });
8160
+ this.parkedEvictions.set(deviceId, {
8161
+ deviceBound: 0,
8162
+ nodeBudget: 0,
8163
+ age: 0
8164
+ });
8165
+ }
8166
+ /**
7665
8167
  * Turn a finished frame into subject TILES and end its hold — the runner half
7666
8168
  * of R2 (`docs/design/2026-08-13-native-lease-two-tier-redesign.md`).
7667
8169
  *
@@ -7931,11 +8433,14 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
7931
8433
  let nativeResult = null;
7932
8434
  let missReason = null;
7933
8435
  let handleAgeMs = null;
8436
+ /** The camera the lookup attributed the handle to, live or expired. */
8437
+ let deviceIdForMiss = null;
7934
8438
  let workerReason;
7935
8439
  if (!isUnboundedFullFrame) {
7936
- const found = this.nativeCropRegistry.get(input.handle);
7937
- handleAgeMs = found?.ageMs ?? null;
7938
- if (found) {
8440
+ const found = this.nativeCropRegistry.lookup(input.handle);
8441
+ handleAgeMs = found.kind === "unknown" ? null : found.ageMs;
8442
+ deviceIdForMiss = found.kind === "unknown" ? null : found.deviceId;
8443
+ if (found.kind === "live") {
7939
8444
  const attempt = await found.request(input.bbox, input.maxWidth);
7940
8445
  if (attempt.kind === "crop") nativeResult = {
7941
8446
  bytes: attempt.crop.bytes,
@@ -7947,11 +8452,12 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
7947
8452
  missReason = "worker-lease-gone";
7948
8453
  workerReason = attempt.workerReason;
7949
8454
  }
7950
- } else missReason = "no-registry-entry";
8455
+ } else if (found.kind === "expired") missReason = "handle-expired";
8456
+ else missReason = "no-registry-entry";
7951
8457
  if (missReason !== null) {
7952
8458
  const tierOnMiss = resolveNativeCropSource(isFullFrame, false);
7953
8459
  const fields = {
7954
- tags: { deviceId: found?.deviceId ?? this.nativeCropRegistry.deviceIdFor(input.handle) ?? -1 },
8460
+ tags: { deviceId: deviceIdForMiss ?? this.nativeCropRegistry.deviceIdFor(input.handle) ?? -1 },
7955
8461
  meta: {
7956
8462
  reason: missReason,
7957
8463
  servedTier: tierOnMiss,
@@ -7963,8 +8469,16 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
7963
8469
  ...workerReason !== void 0 ? { workerReason } : {}
7964
8470
  }
7965
8471
  };
7966
- if (tierOnMiss === "miss") this.ctx.logger.warn("native crop miss", fields);
7967
- else this.ctx.logger.debug("native crop miss", fields);
8472
+ const message = missReason === "handle-expired" ? "native crop refused — the handle is past the lease age bound" : "native crop miss";
8473
+ const withBound = missReason === "handle-expired" ? {
8474
+ ...fields,
8475
+ meta: {
8476
+ ...fields.meta,
8477
+ leaseMaxAgeMs: NATIVE_CROP_LEASE_MAX_AGE_MS
8478
+ }
8479
+ } : fields;
8480
+ if (tierOnMiss === "miss") this.ctx.logger.warn(message, withBound);
8481
+ else this.ctx.logger.debug(message, withBound);
7968
8482
  }
7969
8483
  }
7970
8484
  return {
@@ -9347,7 +9861,13 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
9347
9861
  recipe,
9348
9862
  ms: Date.now() - started,
9349
9863
  now,
9350
- classes: fused.slice(result.detections.length).map((d) => `${d.macroClass}:${d.score.toFixed(3)}`)
9864
+ classes: fused.slice(result.detections.length).map((d) => `${d.macroClass}:${d.score.toFixed(3)}`),
9865
+ boxes: fused.slice(result.detections.length).map((d) => ({
9866
+ x: Math.round(d.bbox.x),
9867
+ y: Math.round(d.bbox.y),
9868
+ w: Math.round(d.bbox.width),
9869
+ h: Math.round(d.bbox.height)
9870
+ }))
9351
9871
  });
9352
9872
  return added > 0 ? {
9353
9873
  ...result,
@@ -9437,6 +9957,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
9437
9957
  crops: sample.crops,
9438
9958
  recovered: sample.recovered,
9439
9959
  classes: sample.classes,
9960
+ boxes: sample.boxes,
9440
9961
  ms: sample.ms
9441
9962
  }
9442
9963
  });