@camstack/addon-pipeline 1.2.69 → 1.2.71

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 (29) 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 +32 -30
  4. package/dist/detection-pipeline/index.mjs +32 -30
  5. package/dist/{dist-tJF2BzWi.js → dist-CtOVWTCi.js} +586 -41
  6. package/dist/{dist-DGLqaonv.mjs → dist-D1Xk5Tji.mjs} +586 -41
  7. package/dist/{event-loop-stall-monitor-D9d2e49f.js → event-loop-stall-monitor-BJs7Yws7.js} +1 -1
  8. package/dist/{event-loop-stall-monitor-C6_VDupx.mjs → event-loop-stall-monitor-Cmbt664E.mjs} +1 -1
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +100 -30
  12. package/dist/pipeline-runner/index.mjs +100 -30
  13. package/dist/recorder/index.js +734 -104
  14. package/dist/recorder/index.mjs +734 -104
  15. package/dist/session-decode/decode-worker-child.js +483 -52
  16. package/dist/session-decode/decode-worker-child.mjs +483 -52
  17. package/dist/stream-broker/_stub.js +660 -556
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DCyQ7RKH.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BWf4H4Nl.mjs} +3 -3
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Cu_kQpIy.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-CExO0piw.mjs +26 -0
  21. package/dist/stream-broker/{hostInit-DxqtIjAe.mjs → hostInit-DD5Z1U6p.mjs} +3 -3
  22. package/dist/stream-broker/index.js +501 -32
  23. package/dist/stream-broker/index.mjs +501 -32
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/{worker-protocol-D6tXz9Or.js → worker-protocol-DEV7g32b.js} +26 -19
  26. package/dist/{worker-protocol-DuftUFEI.mjs → worker-protocol-Dm6LQ9Ls.mjs} +26 -19
  27. package/package.json +1 -1
  28. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-ircnzt0s.mjs +0 -26
  29. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DU7u9EJt.mjs +0 -26
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-tJF2BzWi.js");
1
+ const require_dist = require("./dist-CtOVWTCi.js");
2
2
  let node_perf_hooks = require("node:perf_hooks");
3
3
  //#region src/detection-pipeline/registry/model-catalogs.ts
4
4
  var HF_REPO = "camstack/camstack-models";
@@ -1,4 +1,4 @@
1
- import { a as COCO_TO_MACRO, i as COCO_80_LABELS, r as AUDIO_MACRO_LABELS, z as hfModelUrl } from "./dist-DGLqaonv.mjs";
1
+ import { a as COCO_TO_MACRO, i as COCO_80_LABELS, r as AUDIO_MACRO_LABELS, z as hfModelUrl } from "./dist-D1Xk5Tji.mjs";
2
2
  import { PerformanceObserver } from "node:perf_hooks";
3
3
  //#region src/detection-pipeline/registry/model-catalogs.ts
4
4
  var HF_REPO = "camstack/camstack-models";
@@ -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-tJF2BzWi.js");
5
+ const require_dist = require("../dist-CtOVWTCi.js");
6
6
  const require_lazy_sharp = require("../lazy-sharp-CcYuXtsa.js");
7
7
  let node_fs = require("node:fs");
8
8
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { H as motionDetectionCapability, R as evaluateZoneRules, ft as hydrateSchema, st as BaseAddon, ut as DeviceType } from "../dist-DGLqaonv.mjs";
1
+ import { H as motionDetectionCapability, R as evaluateZoneRules, ft as hydrateSchema, st as BaseAddon, ut as DeviceType } from "../dist-D1Xk5Tji.mjs";
2
2
  import { t as getSharp } from "../lazy-sharp-B-mb8uWx.mjs";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
@@ -3,9 +3,9 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-tJF2BzWi.js");
7
- const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-D9d2e49f.js");
8
- const require_worker_protocol = require("../worker-protocol-D6tXz9Or.js");
6
+ const require_dist = require("../dist-CtOVWTCi.js");
7
+ const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-BJs7Yws7.js");
8
+ const require_worker_protocol = require("../worker-protocol-DEV7g32b.js");
9
9
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
10
10
  const require_lazy_sharp = require("../lazy-sharp-CcYuXtsa.js");
11
11
  const require_remote_restream = require("../remote-restream-BYbAsgUf.js");
@@ -17,28 +17,6 @@ let node_perf_hooks = require("node:perf_hooks");
17
17
  let node_os = require("node:os");
18
18
  node_os = require_chunk.__toESM(node_os);
19
19
  //#region src/session-decode/fork-decode-worker.ts
20
- /**
21
- * Real `fork` implementation for the session-decode coordinator — Epic C P1,
22
- * Task 4.
23
- *
24
- * Spawns the decode-worker child (`decode-worker-child.ts`, Task 2) as a
25
- * dedicated `child_process.fork` and adapts the returned `ChildProcess` to the
26
- * coordinator's minimal {@link ForkedChildLike} contract WITHOUT any cast —
27
- * an explicit forwarding wrapper, so a wider Node `ChildProcess` signature
28
- * never has to structurally satisfy the narrower interface.
29
- *
30
- * `serialization: 'advanced'` is MANDATORY: Node's default 'json' IPC
31
- * base64-serialises the `buffer` reply's `Uint8Array`, breaking the "raw
32
- * bytes, not base64" contract the coordinator + `worker-protocol.ts`'s
33
- * `value.bytes instanceof Uint8Array` guard both depend on. Advanced (V8
34
- * structured-clone) preserves the typed array on the wire.
35
- *
36
- * The child entry is resolved relative to THIS module's built location. Both
37
- * this module and `pipeline-runner/index.ts` bundle into the SAME entry
38
- * (`dist/pipeline-runner/index.js`, `manualChunks: () => null`), so
39
- * `import.meta.url` here is that file's URL and the sibling child lands at
40
- * `dist/session-decode/decode-worker-child.js` (its own vite build entry).
41
- */
42
20
  /** Absolute path to the built decode-worker child entry (a vite build entry). */
43
21
  var CHILD_ENTRY_PATH = (0, node_url.fileURLToPath)(new URL("../session-decode/decode-worker-child.js", require("url").pathToFileURL(__filename).href));
44
22
  /**
@@ -696,8 +674,24 @@ var SessionDecodeSession = class {
696
674
  });
697
675
  }
698
676
  /**
699
- * Prompt-release the worker's RAM native-frame lease for `frameId`. Fire and
700
- * forget (no reply): a lease also self-expires on its TTL, so a dropped
677
+ * The runner has this frame's detections: cut one native subject tile per box
678
+ * and END the hold on `frameId`. Fire and forget (no reply) so the encode can
679
+ * never delay the runner; a dropped message costs only the tiles.
680
+ *
681
+ * An EMPTY box list is still sent — it is how a frame that detected nothing
682
+ * releases its hold, and that is the common case.
683
+ */
684
+ cutTiles(frameId, bboxes) {
685
+ if (this.exited) return;
686
+ this.trySend({
687
+ kind: "cutTiles",
688
+ frameId,
689
+ bboxes
690
+ });
691
+ }
692
+ /**
693
+ * Prompt-release the worker's RAM native-frame hold for `frameId`. Fire and
694
+ * forget (no reply): the hold is also bounded by a frame count, so a dropped
701
695
  * release only defers reclaim. No-op once the worker has exited.
702
696
  */
703
697
  releaseLease(frameId) {
@@ -977,6 +971,7 @@ function buildFrameImage(session, reply) {
977
971
  return session.requestBuffer(reply.frameId, opts);
978
972
  },
979
973
  nativeCrop: (bbox, maxWidth) => session.requestNativeCrop(reply.frameId, bbox, maxWidth),
974
+ cutTiles: (bboxes) => session.cutTiles(reply.frameId, bboxes),
980
975
  releaseNativeLease: () => session.releaseLease(reply.frameId),
981
976
  markInferred: () => session.markInferred(reply.frameId),
982
977
  close: () => {
@@ -1117,6 +1112,7 @@ function startSessionDecodePump(deps) {
1117
1112
  const image = frame.image;
1118
1113
  const cropFn = image.nativeCrop;
1119
1114
  const nativeCrop = cropFn ? Object.assign((bbox, maxWidth) => cropFn(bbox, maxWidth), {
1115
+ cutTiles: (bboxes) => image.cutTiles?.(bboxes),
1120
1116
  release: () => image.releaseNativeLease?.(),
1121
1117
  markInferred: () => image.markInferred?.()
1122
1118
  }) : void 0;
@@ -2525,7 +2521,7 @@ var NATIVE_LEASE_OWNER_ADDON_ID = "pipeline-orchestrator";
2525
2521
  * old value.
2526
2522
  */
2527
2523
  function sameOverride(a, b) {
2528
- return a.ttlMs === b.ttlMs && a.budgetMb === b.budgetMb && a.activityMs === b.activityMs && a.admission === b.admission;
2524
+ return a.holdFrames === b.holdFrames && a.budgetMb === b.budgetMb && a.activityMs === b.activityMs && a.admission === b.admission && a.tileBudgetMb === b.tileBudgetMb;
2529
2525
  }
2530
2526
  /**
2531
2527
  * TTL-cached read of the cluster native-lease knobs.
@@ -3190,6 +3186,7 @@ var PipelineRunner = class {
3190
3186
  motionQueue,
3191
3187
  detectionQueue,
3192
3188
  inferenceTimes: [],
3189
+ shedFrames: 0,
3193
3190
  processedCount: 0,
3194
3191
  startTime: Date.now(),
3195
3192
  phase: initialPhase,
@@ -3482,7 +3479,7 @@ var PipelineRunner = class {
3482
3479
  actualFps,
3483
3480
  queueDepth: state.motionQueue.size + state.detectionQueue.size,
3484
3481
  avgInferenceTimeMs: avgInference,
3485
- droppedFrames: state.motionQueue.droppedFrames + state.detectionQueue.droppedFrames,
3482
+ droppedFrames: state.motionQueue.droppedFrames + state.detectionQueue.droppedFrames + state.shedFrames,
3486
3483
  phase: state.phase
3487
3484
  };
3488
3485
  }
@@ -3589,6 +3586,7 @@ var PipelineRunner = class {
3589
3586
  if (state.inferenceTimes.length > 100) state.inferenceTimes.shift();
3590
3587
  state.processedCount++;
3591
3588
  if (result) {
3589
+ if (result.shed === true) state.shedFrames++;
3592
3590
  await this.notifyCallbacks(deviceId, frame, result, streamType, handle);
3593
3591
  const emittedAt = Date.now();
3594
3592
  const capturedAt = frame.capturedAt;
@@ -3800,6 +3798,42 @@ async function runStatelessStep(deps, input) {
3800
3798
  details
3801
3799
  };
3802
3800
  }
3801
+ /**
3802
+ * Normalised boxes for the subjects worth a tile, highest confidence first,
3803
+ * capped.
3804
+ *
3805
+ * Returns an empty array for a frame with no usable detections — and the caller
3806
+ * still sends it: an empty `cutTiles` is how a frame that detected nothing ends
3807
+ * its hold, which is the majority of frames and the whole reason this design
3808
+ * costs less than the one it replaces.
3809
+ *
3810
+ * A degenerate or non-finite box is DROPPED rather than clamped: a zero-area
3811
+ * container serves nothing, and a NaN one would poison the worker's geometry.
3812
+ */
3813
+ function planTileCuts(detections, frame, maxSubjects = 4) {
3814
+ if (frame.width <= 0 || frame.height <= 0) return [];
3815
+ const ordered = [...detections.filter((d) => isUsable(d.bbox))].sort((a, b) => b.score - a.score);
3816
+ const out = [];
3817
+ for (const detection of ordered.slice(0, Math.max(0, maxSubjects))) {
3818
+ const { x, y, width, height } = detection.bbox;
3819
+ out.push({
3820
+ x: x / frame.width,
3821
+ y: y / frame.height,
3822
+ w: width / frame.width,
3823
+ h: height / frame.height
3824
+ });
3825
+ }
3826
+ return out;
3827
+ }
3828
+ function isUsable(bbox) {
3829
+ if (![
3830
+ bbox.x,
3831
+ bbox.y,
3832
+ bbox.width,
3833
+ bbox.height
3834
+ ].every((v) => Number.isFinite(v))) return false;
3835
+ return bbox.width > 0 && bbox.height > 0;
3836
+ }
3803
3837
  //#endregion
3804
3838
  //#region src/pipeline-runner/index.ts
3805
3839
  var DEFAULT_CONFIG = {
@@ -4288,6 +4322,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
4288
4322
  this.handleDetectionStreamChange(deviceId, action);
4289
4323
  });
4290
4324
  this.runner.onResult(async (deviceId, frame, result, _streamType, handle) => {
4325
+ this.cutSubjectTiles(deviceId, result, handle);
4291
4326
  this.emitInferenceResult(deviceId, frame, result, handle);
4292
4327
  const detectionCount = result.detections?.length ?? 0;
4293
4328
  this.runner?.noteDetectionResult(deviceId, detectionCount);
@@ -5109,6 +5144,40 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5109
5144
  }, resolved.tier, input.encodeJpeg);
5110
5145
  }
5111
5146
  /**
5147
+ * Turn a finished frame into subject TILES and end its hold — the runner half
5148
+ * of R2 (`docs/design/2026-08-13-native-lease-two-tier-redesign.md`).
5149
+ *
5150
+ * Called from the result callback, which is the last instant at which the
5151
+ * frame's native pixels exist and its detections are known at the same time.
5152
+ * Every path through here ends the hold, including the one where there is
5153
+ * nothing to cut: a frame that detected nothing is the majority of frames and
5154
+ * releasing it promptly is most of the RAM this redesign gives back.
5155
+ *
5156
+ * Deliberately synchronous and unawaited. The worker replies to nothing, so
5157
+ * there is nothing to wait for, and a detection result must never be delayed
5158
+ * by an encode.
5159
+ */
5160
+ cutSubjectTiles(deviceId, result, handle) {
5161
+ if (!handle) return;
5162
+ const found = this.nativeCropRegistry.get(handle);
5163
+ if (!found) return;
5164
+ const boxes = planTileCuts(result.detections ?? [], {
5165
+ width: result.width,
5166
+ height: result.height
5167
+ });
5168
+ try {
5169
+ found.request.cutTiles(boxes);
5170
+ } catch (err) {
5171
+ this.ctx.logger.debug("subject tile cut could not be dispatched", {
5172
+ tags: { deviceId },
5173
+ meta: {
5174
+ subjects: boxes.length,
5175
+ error: err instanceof Error ? err.message : String(err)
5176
+ }
5177
+ });
5178
+ }
5179
+ }
5180
+ /**
5112
5181
  * The native-lease half of {@link getNativeCrop}, extracted so the DETAIL path
5113
5182
  * can keep the miss REASON the cap's `null` return has no room for. Both
5114
5183
  * callers share this one decision — a second copy of the tier arithmetic is
@@ -6102,11 +6171,12 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
6102
6171
  let image;
6103
6172
  if (!this.executorCoLocated()) {
6104
6173
  if (frame.format === "rgb") try {
6105
- image = await (0, sharp.default)(frame.data, { raw: {
6174
+ const encoded = await (0, sharp.default)(frame.data, { raw: {
6106
6175
  width: frame.width,
6107
6176
  height: frame.height,
6108
6177
  channels: 3
6109
6178
  } }).jpeg({ quality: DETECTION_HOP_JPEG_QUALITY }).toBuffer();
6179
+ image = new Uint8Array(encoded);
6110
6180
  } catch (err) {
6111
6181
  log.debug("runInference: frame JPEG encode failed — falling back to raw pixels", { meta: { error: require_dist.errMsg(err) } });
6112
6182
  }
@@ -1,6 +1,6 @@
1
- import { At as string, Ct as boolean, Dt as number, Et as literal, K as pipelineRunnerCapability, M as deriveDetailCropRect, Mt as EventCategory, Ot as object, St as array, Tt as lazy, U as pickDetailCropConvention, W as pickNativeLeaseOverride, dt as createEvent, gt as nodePin, j as defineCustomActions, k as customAction, s as DEFAULT_DETAIL_CROP_CONVENTION, st as BaseAddon, tt as errMsg, u as DEVICE_BACKEND_TO_FORMAT, xt as _enum } from "../dist-DGLqaonv.mjs";
2
- import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-C6_VDupx.mjs";
3
- import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-DuftUFEI.mjs";
1
+ import { At as string, Ct as boolean, Dt as number, Et as literal, K as pipelineRunnerCapability, M as deriveDetailCropRect, Mt as EventCategory, Ot as object, St as array, Tt as lazy, U as pickDetailCropConvention, W as pickNativeLeaseOverride, dt as createEvent, gt as nodePin, j as defineCustomActions, k as customAction, s as DEFAULT_DETAIL_CROP_CONVENTION, st as BaseAddon, tt as errMsg, u as DEVICE_BACKEND_TO_FORMAT, xt as _enum } from "../dist-D1Xk5Tji.mjs";
2
+ import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-Cmbt664E.mjs";
3
+ import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-Dm6LQ9Ls.mjs";
4
4
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
5
5
  import { t as getSharp } from "../lazy-sharp-B-mb8uWx.mjs";
6
6
  import { i as withDetectionIntent, n as profileForStreamId, t as isRemoteRestream } from "../remote-restream-Ci7RXNGb.mjs";
@@ -10,28 +10,6 @@ import { fileURLToPath } from "node:url";
10
10
  import { PerformanceObserver } from "node:perf_hooks";
11
11
  import * as os from "node:os";
12
12
  //#region src/session-decode/fork-decode-worker.ts
13
- /**
14
- * Real `fork` implementation for the session-decode coordinator — Epic C P1,
15
- * Task 4.
16
- *
17
- * Spawns the decode-worker child (`decode-worker-child.ts`, Task 2) as a
18
- * dedicated `child_process.fork` and adapts the returned `ChildProcess` to the
19
- * coordinator's minimal {@link ForkedChildLike} contract WITHOUT any cast —
20
- * an explicit forwarding wrapper, so a wider Node `ChildProcess` signature
21
- * never has to structurally satisfy the narrower interface.
22
- *
23
- * `serialization: 'advanced'` is MANDATORY: Node's default 'json' IPC
24
- * base64-serialises the `buffer` reply's `Uint8Array`, breaking the "raw
25
- * bytes, not base64" contract the coordinator + `worker-protocol.ts`'s
26
- * `value.bytes instanceof Uint8Array` guard both depend on. Advanced (V8
27
- * structured-clone) preserves the typed array on the wire.
28
- *
29
- * The child entry is resolved relative to THIS module's built location. Both
30
- * this module and `pipeline-runner/index.ts` bundle into the SAME entry
31
- * (`dist/pipeline-runner/index.js`, `manualChunks: () => null`), so
32
- * `import.meta.url` here is that file's URL and the sibling child lands at
33
- * `dist/session-decode/decode-worker-child.js` (its own vite build entry).
34
- */
35
13
  /** Absolute path to the built decode-worker child entry (a vite build entry). */
36
14
  var CHILD_ENTRY_PATH = fileURLToPath(new URL("../session-decode/decode-worker-child.js", import.meta.url));
37
15
  /**
@@ -689,8 +667,24 @@ var SessionDecodeSession = class {
689
667
  });
690
668
  }
691
669
  /**
692
- * Prompt-release the worker's RAM native-frame lease for `frameId`. Fire and
693
- * forget (no reply): a lease also self-expires on its TTL, so a dropped
670
+ * The runner has this frame's detections: cut one native subject tile per box
671
+ * and END the hold on `frameId`. Fire and forget (no reply) so the encode can
672
+ * never delay the runner; a dropped message costs only the tiles.
673
+ *
674
+ * An EMPTY box list is still sent — it is how a frame that detected nothing
675
+ * releases its hold, and that is the common case.
676
+ */
677
+ cutTiles(frameId, bboxes) {
678
+ if (this.exited) return;
679
+ this.trySend({
680
+ kind: "cutTiles",
681
+ frameId,
682
+ bboxes
683
+ });
684
+ }
685
+ /**
686
+ * Prompt-release the worker's RAM native-frame hold for `frameId`. Fire and
687
+ * forget (no reply): the hold is also bounded by a frame count, so a dropped
694
688
  * release only defers reclaim. No-op once the worker has exited.
695
689
  */
696
690
  releaseLease(frameId) {
@@ -970,6 +964,7 @@ function buildFrameImage(session, reply) {
970
964
  return session.requestBuffer(reply.frameId, opts);
971
965
  },
972
966
  nativeCrop: (bbox, maxWidth) => session.requestNativeCrop(reply.frameId, bbox, maxWidth),
967
+ cutTiles: (bboxes) => session.cutTiles(reply.frameId, bboxes),
973
968
  releaseNativeLease: () => session.releaseLease(reply.frameId),
974
969
  markInferred: () => session.markInferred(reply.frameId),
975
970
  close: () => {
@@ -1110,6 +1105,7 @@ function startSessionDecodePump(deps) {
1110
1105
  const image = frame.image;
1111
1106
  const cropFn = image.nativeCrop;
1112
1107
  const nativeCrop = cropFn ? Object.assign((bbox, maxWidth) => cropFn(bbox, maxWidth), {
1108
+ cutTiles: (bboxes) => image.cutTiles?.(bboxes),
1113
1109
  release: () => image.releaseNativeLease?.(),
1114
1110
  markInferred: () => image.markInferred?.()
1115
1111
  }) : void 0;
@@ -2517,7 +2513,7 @@ var NATIVE_LEASE_OWNER_ADDON_ID = "pipeline-orchestrator";
2517
2513
  * old value.
2518
2514
  */
2519
2515
  function sameOverride(a, b) {
2520
- return a.ttlMs === b.ttlMs && a.budgetMb === b.budgetMb && a.activityMs === b.activityMs && a.admission === b.admission;
2516
+ return a.holdFrames === b.holdFrames && a.budgetMb === b.budgetMb && a.activityMs === b.activityMs && a.admission === b.admission && a.tileBudgetMb === b.tileBudgetMb;
2521
2517
  }
2522
2518
  /**
2523
2519
  * TTL-cached read of the cluster native-lease knobs.
@@ -3182,6 +3178,7 @@ var PipelineRunner = class {
3182
3178
  motionQueue,
3183
3179
  detectionQueue,
3184
3180
  inferenceTimes: [],
3181
+ shedFrames: 0,
3185
3182
  processedCount: 0,
3186
3183
  startTime: Date.now(),
3187
3184
  phase: initialPhase,
@@ -3474,7 +3471,7 @@ var PipelineRunner = class {
3474
3471
  actualFps,
3475
3472
  queueDepth: state.motionQueue.size + state.detectionQueue.size,
3476
3473
  avgInferenceTimeMs: avgInference,
3477
- droppedFrames: state.motionQueue.droppedFrames + state.detectionQueue.droppedFrames,
3474
+ droppedFrames: state.motionQueue.droppedFrames + state.detectionQueue.droppedFrames + state.shedFrames,
3478
3475
  phase: state.phase
3479
3476
  };
3480
3477
  }
@@ -3581,6 +3578,7 @@ var PipelineRunner = class {
3581
3578
  if (state.inferenceTimes.length > 100) state.inferenceTimes.shift();
3582
3579
  state.processedCount++;
3583
3580
  if (result) {
3581
+ if (result.shed === true) state.shedFrames++;
3584
3582
  await this.notifyCallbacks(deviceId, frame, result, streamType, handle);
3585
3583
  const emittedAt = Date.now();
3586
3584
  const capturedAt = frame.capturedAt;
@@ -3792,6 +3790,42 @@ async function runStatelessStep(deps, input) {
3792
3790
  details
3793
3791
  };
3794
3792
  }
3793
+ /**
3794
+ * Normalised boxes for the subjects worth a tile, highest confidence first,
3795
+ * capped.
3796
+ *
3797
+ * Returns an empty array for a frame with no usable detections — and the caller
3798
+ * still sends it: an empty `cutTiles` is how a frame that detected nothing ends
3799
+ * its hold, which is the majority of frames and the whole reason this design
3800
+ * costs less than the one it replaces.
3801
+ *
3802
+ * A degenerate or non-finite box is DROPPED rather than clamped: a zero-area
3803
+ * container serves nothing, and a NaN one would poison the worker's geometry.
3804
+ */
3805
+ function planTileCuts(detections, frame, maxSubjects = 4) {
3806
+ if (frame.width <= 0 || frame.height <= 0) return [];
3807
+ const ordered = [...detections.filter((d) => isUsable(d.bbox))].sort((a, b) => b.score - a.score);
3808
+ const out = [];
3809
+ for (const detection of ordered.slice(0, Math.max(0, maxSubjects))) {
3810
+ const { x, y, width, height } = detection.bbox;
3811
+ out.push({
3812
+ x: x / frame.width,
3813
+ y: y / frame.height,
3814
+ w: width / frame.width,
3815
+ h: height / frame.height
3816
+ });
3817
+ }
3818
+ return out;
3819
+ }
3820
+ function isUsable(bbox) {
3821
+ if (![
3822
+ bbox.x,
3823
+ bbox.y,
3824
+ bbox.width,
3825
+ bbox.height
3826
+ ].every((v) => Number.isFinite(v))) return false;
3827
+ return bbox.width > 0 && bbox.height > 0;
3828
+ }
3795
3829
  //#endregion
3796
3830
  //#region src/pipeline-runner/index.ts
3797
3831
  var DEFAULT_CONFIG = {
@@ -4280,6 +4314,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4280
4314
  this.handleDetectionStreamChange(deviceId, action);
4281
4315
  });
4282
4316
  this.runner.onResult(async (deviceId, frame, result, _streamType, handle) => {
4317
+ this.cutSubjectTiles(deviceId, result, handle);
4283
4318
  this.emitInferenceResult(deviceId, frame, result, handle);
4284
4319
  const detectionCount = result.detections?.length ?? 0;
4285
4320
  this.runner?.noteDetectionResult(deviceId, detectionCount);
@@ -5101,6 +5136,40 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5101
5136
  }, resolved.tier, input.encodeJpeg);
5102
5137
  }
5103
5138
  /**
5139
+ * Turn a finished frame into subject TILES and end its hold — the runner half
5140
+ * of R2 (`docs/design/2026-08-13-native-lease-two-tier-redesign.md`).
5141
+ *
5142
+ * Called from the result callback, which is the last instant at which the
5143
+ * frame's native pixels exist and its detections are known at the same time.
5144
+ * Every path through here ends the hold, including the one where there is
5145
+ * nothing to cut: a frame that detected nothing is the majority of frames and
5146
+ * releasing it promptly is most of the RAM this redesign gives back.
5147
+ *
5148
+ * Deliberately synchronous and unawaited. The worker replies to nothing, so
5149
+ * there is nothing to wait for, and a detection result must never be delayed
5150
+ * by an encode.
5151
+ */
5152
+ cutSubjectTiles(deviceId, result, handle) {
5153
+ if (!handle) return;
5154
+ const found = this.nativeCropRegistry.get(handle);
5155
+ if (!found) return;
5156
+ const boxes = planTileCuts(result.detections ?? [], {
5157
+ width: result.width,
5158
+ height: result.height
5159
+ });
5160
+ try {
5161
+ found.request.cutTiles(boxes);
5162
+ } catch (err) {
5163
+ this.ctx.logger.debug("subject tile cut could not be dispatched", {
5164
+ tags: { deviceId },
5165
+ meta: {
5166
+ subjects: boxes.length,
5167
+ error: err instanceof Error ? err.message : String(err)
5168
+ }
5169
+ });
5170
+ }
5171
+ }
5172
+ /**
5104
5173
  * The native-lease half of {@link getNativeCrop}, extracted so the DETAIL path
5105
5174
  * can keep the miss REASON the cap's `null` return has no room for. Both
5106
5175
  * callers share this one decision — a second copy of the tier arithmetic is
@@ -6094,11 +6163,12 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6094
6163
  let image;
6095
6164
  if (!this.executorCoLocated()) {
6096
6165
  if (frame.format === "rgb") try {
6097
- image = await sharp(frame.data, { raw: {
6166
+ const encoded = await sharp(frame.data, { raw: {
6098
6167
  width: frame.width,
6099
6168
  height: frame.height,
6100
6169
  channels: 3
6101
6170
  } }).jpeg({ quality: DETECTION_HOP_JPEG_QUALITY }).toBuffer();
6171
+ image = new Uint8Array(encoded);
6102
6172
  } catch (err) {
6103
6173
  log.debug("runInference: frame JPEG encode failed — falling back to raw pixels", { meta: { error: errMsg(err) } });
6104
6174
  }