@camstack/addon-pipeline 1.2.107 → 1.2.118

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 (35) hide show
  1. package/dist/{addon-utils-A2S9D7pu.mjs → addon-utils-CgBCF-mE.mjs} +50 -14
  2. package/dist/{addon-utils-Vv2dCqA2.js → addon-utils-DJSN5h8c.js} +51 -15
  3. package/dist/audio-analyzer/index.js +3 -3
  4. package/dist/audio-analyzer/index.mjs +3 -3
  5. package/dist/detection-pipeline/index.js +170 -31
  6. package/dist/detection-pipeline/index.mjs +169 -30
  7. package/dist/{dist-CkXAzTZ2.mjs → dist-B763E61Y.mjs} +152 -26
  8. package/dist/{dist-L-1LMGQs.js → dist-BkO76jAp.js} +152 -26
  9. package/dist/{event-loop-stall-monitor-DwAho7HD.js → event-loop-stall-monitor-28f4R0JN.js} +234 -1
  10. package/dist/{event-loop-stall-monitor-fHkfgmlw.mjs → event-loop-stall-monitor-CYEvvp2Z.mjs} +223 -2
  11. package/dist/{lazy-sharp-DfbyQxET.js → lazy-sharp-BaPl5mRa.js} +1 -1
  12. package/dist/motion-wasm/index.js +2 -2
  13. package/dist/motion-wasm/index.mjs +1 -1
  14. package/dist/pipeline-runner/index.js +915 -107
  15. package/dist/pipeline-runner/index.mjs +914 -106
  16. package/dist/{process-memory-CgrbLo3l.js → process-memory-BCQJFZZg.js} +1 -1
  17. package/dist/{process-memory-DlSOzuHP.mjs → process-memory-CK3FnKyZ.mjs} +1 -1
  18. package/dist/recorder/index.js +446 -77
  19. package/dist/recorder/index.mjs +446 -77
  20. package/dist/session-decode/decode-worker-child.js +62 -34
  21. package/dist/session-decode/decode-worker-child.mjs +61 -33
  22. package/dist/stream-broker/_stub.js +2 -2
  23. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BeL4_jgH.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CxgvfkxP.mjs} +3 -3
  24. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CL6Lgzdj.mjs +26 -0
  25. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CmHtm5CX.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BAMyzCWw.mjs} +1 -1
  26. package/dist/stream-broker/{hostInit-BK9nzKAG.mjs → hostInit-CTkpxp3w.mjs} +3 -3
  27. package/dist/stream-broker/index.js +279 -198
  28. package/dist/stream-broker/index.mjs +279 -198
  29. package/dist/stream-broker/remoteEntry.js +1 -1
  30. package/dist/{worker-protocol-bEipQT9l.mjs → worker-protocol-D_2QaXYr.mjs} +6 -3
  31. package/dist/{worker-protocol-GGnzfWu3.js → worker-protocol-owR7WMRP.js} +6 -3
  32. package/package.json +1 -1
  33. package/python/inference_pool.py +4 -1
  34. package/python/test_inference_pool_preprocess.py +87 -16
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DjndFc5V.mjs +0 -26
@@ -1,6 +1,6 @@
1
- import { At as nodePin, Bt as literal, F as customAction, Ft as _enum, Ht as object, It as array, Kt as EventCategory, L as defineCustomActions, Lt as boolean, Tt as createEvent, Vt as number, Wt as string, et as pickDetailCropConvention, ht as errMsg, p as DEVICE_BACKEND_TO_FORMAT, rt as pipelineRunnerCapability, tt as pickNativeLeaseOverride, u as DEFAULT_DETAIL_CROP_CONVENTION, xt as BaseAddon, z as deriveDetailCropRect, zt as lazy } from "../dist-CkXAzTZ2.mjs";
2
- import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-fHkfgmlw.mjs";
3
- import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-bEipQT9l.mjs";
1
+ import { At as nodePin, Bt as literal, F as customAction, Ft as _enum, Ht as object, It as array, Kt as EventCategory, L as defineCustomActions, Lt as boolean, Tt as createEvent, Vt as number, Wt as string, et as pickDetailCropConvention, ht as errMsg, p as DEVICE_BACKEND_TO_FORMAT, rt as pipelineRunnerCapability, tt as pickNativeLeaseOverride, u as DEFAULT_DETAIL_CROP_CONVENTION, xt as BaseAddon, z as deriveDetailCropRect, zt as lazy } from "../dist-B763E61Y.mjs";
2
+ import { i as resolveFrameViewGeometry, r as localFrameRegistry, t as attributeStall, u as getStepDefinition } from "../event-loop-stall-monitor-CYEvvp2Z.mjs";
3
+ import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-D_2QaXYr.mjs";
4
4
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
5
5
  import { t as getSharp } from "../lazy-sharp-6oymT_yf.mjs";
6
6
  import { n as withDetectionIntent } from "../restream-intent-B4BXZra7.mjs";
@@ -297,6 +297,48 @@ var NativeCropRegistry = class {
297
297
  }
298
298
  }
299
299
  };
300
+ //#endregion
301
+ //#region src/session-decode/retained-content-fallback.ts
302
+ /**
303
+ * Honest ≤640 RAM fallback for `getNativeCrop` full-frame misses.
304
+ * Content-sized contain (16:9 → 640×360, 4:3 → 640×480, square → 640×640),
305
+ * never a detection default and never a cross-process ref. Task 3 owns
306
+ * whether this rung stays after telemetry.
307
+ */
308
+ var RETAINED_FALLBACK_VIEW = {
309
+ content: {
310
+ width: 640,
311
+ height: 640
312
+ },
313
+ fit: "contain",
314
+ format: "rgb"
315
+ };
316
+ /** Resolve a content-sized RGB view from the process-local registry on demand. */
317
+ function createRetainedContentFallback(registry, ref) {
318
+ return { async materialize() {
319
+ const lease = registry.acquire(ref);
320
+ if (!lease) return null;
321
+ let reason = "error";
322
+ try {
323
+ const view = await lease.resolve(RETAINED_FALLBACK_VIEW);
324
+ if (view.kind !== "view" || view.format !== "rgb" || view.data.byteLength === 0) {
325
+ reason = "drop";
326
+ return null;
327
+ }
328
+ reason = "success";
329
+ return {
330
+ data: Buffer.from(view.data.buffer, view.data.byteOffset, view.data.byteLength),
331
+ width: view.width,
332
+ height: view.height,
333
+ format: "rgb",
334
+ timestamp: ref.timestamp,
335
+ ...ref.capturedAt !== void 0 ? { capturedAt: ref.capturedAt } : {}
336
+ };
337
+ } finally {
338
+ lease.release(reason);
339
+ }
340
+ } };
341
+ }
300
342
  /** {@link RETAINED_FRAME_BUDGET_MB} in bytes. */
301
343
  var RETAINED_FRAME_BUDGET_BYTES = (() => {
302
344
  const raw = Number(process.env["CAMSTACK_SESSION_RETAIN_MB"]);
@@ -344,12 +386,24 @@ var RETAINED_FRAME_KEY_PREFIX = "ram:";
344
386
  function keyOf(handle) {
345
387
  return `${handle.shmId}#${handle.slot}#${handle.seq}`;
346
388
  }
389
+ /**
390
+ * Node-level RAM store of retained rgb detection frames, keyed by
391
+ * `FrameHandle` identity. Latest-wins with a byte budget.
392
+ */
393
+ /** One retained frame plus the device it belongs to (for the per-device floor). */
394
+ /**
395
+ * Reserved footprint of a lazy contain ≤640 RGB view. Worst-case square
396
+ * 640×640×3 so 4:3 and portrait never exceed the reservation; 16:9 hydrates
397
+ * later settle to 640×360.
398
+ */
399
+ var CONTENT_SIZED_RESERVE_BYTES = 640 * 640 * 3;
347
400
  var RetainedFrameStore = class RetainedFrameStore {
348
401
  nodeId;
349
402
  budgetBytes;
350
403
  perDeviceFloor;
351
404
  timeFloorMs;
352
405
  now;
406
+ onEvict;
353
407
  /** Insertion-ordered (Map) → globally oldest is `keys().next()`. */
354
408
  entries = /* @__PURE__ */ new Map();
355
409
  /**
@@ -368,6 +422,7 @@ var RetainedFrameStore = class RetainedFrameStore {
368
422
  this.perDeviceFloor = options.perDeviceFloor ?? RETAINED_FRAME_PER_DEVICE_FLOOR;
369
423
  this.timeFloorMs = options.timeFloorMs ?? RETAINED_FRAME_TIME_FLOOR_MS;
370
424
  this.now = options.now ?? Date.now;
425
+ this.onEvict = options.onEvict;
371
426
  }
372
427
  /** Number of retained frames (tests / metrics). */
373
428
  get size() {
@@ -384,35 +439,76 @@ var RetainedFrameStore = class RetainedFrameStore {
384
439
  * decode-worker IPC delivers a fresh Buffer per frame, so no defensive copy
385
440
  * is needed.
386
441
  */
387
- write(deviceId, frame) {
442
+ write(deviceId, frame, fallback) {
388
443
  if (frame.format !== "rgb") return null;
389
- const seq = ++this.seq;
390
- const byteLength = frame.data.byteLength;
391
- const handle = {
392
- shmId: `${RETAINED_FRAME_KEY_PREFIX}${this.nodeId}:${deviceId}`,
393
- slot: seq % RetainedFrameStore.SLOT_SPREAD,
394
- seq,
395
- width: frame.width,
396
- height: frame.height,
397
- format: "rgb",
398
- pts: performance.now(),
399
- byteLength,
400
- nodeId: this.nodeId,
401
- slotCount: RetainedFrameStore.SLOT_SPREAD
402
- };
403
- const key = keyOf(handle);
404
- this.entries.set(key, {
444
+ const pixelFree = frame.data.byteLength === 0;
445
+ const handle = this.mintHandle(deviceId, frame);
446
+ const reservedBytes = pixelFree ? CONTENT_SIZED_RESERVE_BYTES : handle.byteLength;
447
+ this.insert(handle, {
405
448
  frame,
406
449
  deviceId,
407
- writtenAt: this.now()
450
+ writtenAt: this.now(),
451
+ reservedBytes,
452
+ ...pixelFree && fallback ? { fallback } : {}
408
453
  });
409
- const deviceKeys = this.perDeviceKeys.get(deviceId);
410
- if (deviceKeys) deviceKeys.push(key);
411
- else this.perDeviceKeys.set(deviceId, [key]);
412
- this.bytes += byteLength;
413
- this.evictToBudget();
414
454
  return handle;
415
455
  }
456
+ /**
457
+ * Identity-only handle for native hold/tile. Does not retain pixels or
458
+ * reserve the content-sized worst-case slot — Task 3 continuous path.
459
+ */
460
+ allocateHandle(deviceId, frame) {
461
+ if (frame.format !== "rgb") return null;
462
+ return this.mintHandle(deviceId, frame);
463
+ }
464
+ /**
465
+ * Remember a detection-only on-demand fallback. Reserves the content-sized
466
+ * worst case until hydrate replaces it with the real JPEG/RGB bytes so the
467
+ * budget can evict unrequested detections. A no-detection frame must never
468
+ * call this.
469
+ */
470
+ rememberOnDemand(deviceId, handle, fallback) {
471
+ const key = keyOf(handle);
472
+ if (this.entries.has(key)) return;
473
+ this.insert(handle, {
474
+ frame: {
475
+ data: Buffer.alloc(0),
476
+ width: handle.width,
477
+ height: handle.height,
478
+ format: "rgb",
479
+ timestamp: 0
480
+ },
481
+ deviceId,
482
+ writtenAt: this.now(),
483
+ reservedBytes: CONTENT_SIZED_RESERVE_BYTES,
484
+ fallback
485
+ });
486
+ }
487
+ has(handle) {
488
+ return this.entries.has(keyOf(handle));
489
+ }
490
+ getDeviceId(handle) {
491
+ return this.entries.get(keyOf(handle))?.deviceId;
492
+ }
493
+ statsByDevice(deviceId) {
494
+ const keys = this.perDeviceKeys.get(deviceId) ?? [];
495
+ let bytes = 0;
496
+ for (const key of keys) bytes += this.entries.get(key)?.reservedBytes ?? 0;
497
+ return {
498
+ entries: keys.length,
499
+ bytes
500
+ };
501
+ }
502
+ deviceStats() {
503
+ return [...this.perDeviceKeys.keys()].map((deviceId) => ({
504
+ deviceId,
505
+ ...this.statsByDevice(deviceId)
506
+ }));
507
+ }
508
+ /** Drop one handle (success/error/drop/detach). Idempotent. */
509
+ drop(handle) {
510
+ this.dropKey(keyOf(handle));
511
+ }
416
512
  /** The retained frame for `handle`, or `null` if never retained / already evicted. */
417
513
  getFrame(handle) {
418
514
  return this.entries.get(keyOf(handle))?.frame ?? null;
@@ -427,11 +523,46 @@ var RetainedFrameStore = class RetainedFrameStore {
427
523
  */
428
524
  crop(handle, bbox, maxWidth) {
429
525
  const entry = this.entries.get(keyOf(handle));
430
- if (!entry || entry.frame.format !== "rgb") return null;
526
+ if (!entry || entry.frame.data.byteLength === 0) return null;
527
+ if (entry.frame.format === "jpeg") {
528
+ if (!(bbox.x <= .001 && bbox.y <= .001 && bbox.w >= .999 && bbox.h >= .999)) return null;
529
+ return {
530
+ bytes: entry.frame.data,
531
+ width: entry.frame.width,
532
+ height: entry.frame.height
533
+ };
534
+ }
535
+ if (entry.frame.format !== "rgb") return null;
431
536
  return cropRgb(entry.frame, bbox, maxWidth);
432
537
  }
538
+ /**
539
+ * Full-frame / ROI crop that hydrates a pixel-free handle on demand.
540
+ * A missing fallback or failed materialize is an explicit miss — never a
541
+ * 0-byte raster.
542
+ */
543
+ async cropAsync(handle, bbox, maxWidth) {
544
+ await this.ensureContent(handle);
545
+ return this.crop(handle, bbox, maxWidth);
546
+ }
547
+ /** Materialize a content-sized RGB view once, if the handle still has a fallback. */
548
+ async ensureContent(handle) {
549
+ const key = keyOf(handle);
550
+ const entry = this.entries.get(key);
551
+ if (!entry) return null;
552
+ if (entry.frame.data.byteLength > 0) return entry.frame;
553
+ if (entry.hydrating) return entry.hydrating;
554
+ if (!entry.fallback) return null;
555
+ entry.hydrating = this.hydrate(key, entry);
556
+ try {
557
+ return await entry.hydrating;
558
+ } finally {
559
+ const current = this.entries.get(key);
560
+ if (current) current.hydrating = void 0;
561
+ }
562
+ }
433
563
  /** Drop every retained frame (node/addon shutdown). */
434
564
  clear() {
565
+ for (const entry of this.entries.values()) entry.fallback?.release?.();
435
566
  this.entries.clear();
436
567
  this.perDeviceKeys.clear();
437
568
  this.bytes = 0;
@@ -456,7 +587,7 @@ var RetainedFrameStore = class RetainedFrameStore {
456
587
  if (!entry) continue;
457
588
  if (this.timeFloorMs > 0 && nowMs - entry.writtenAt < this.timeFloorMs) continue;
458
589
  const deviceKeys = this.perDeviceKeys.get(entry.deviceId);
459
- if (deviceKeys && deviceKeys.length > this.perDeviceFloor) this.dropKey(key);
590
+ if (deviceKeys && deviceKeys.length > this.perDeviceFloor) this.dropKey(key, "evict");
460
591
  }
461
592
  if (this.bytes <= this.budgetBytes) return;
462
593
  for (const key of oldestFirst) {
@@ -464,27 +595,68 @@ var RetainedFrameStore = class RetainedFrameStore {
464
595
  const entry = this.entries.get(key);
465
596
  if (!entry) continue;
466
597
  const deviceKeys = this.perDeviceKeys.get(entry.deviceId);
467
- if (deviceKeys && deviceKeys.length > this.perDeviceFloor) this.dropKey(key);
598
+ if (deviceKeys && deviceKeys.length > this.perDeviceFloor) this.dropKey(key, "evict");
468
599
  }
469
600
  if (this.bytes <= this.budgetBytes) return;
470
601
  for (const key of oldestFirst) {
471
602
  if (this.bytes <= this.budgetBytes || this.entries.size <= 1) break;
472
- this.dropKey(key);
603
+ this.dropKey(key, "evict");
473
604
  }
474
605
  if (this.bytes < 0) this.bytes = 0;
475
606
  }
607
+ async hydrate(key, entry) {
608
+ const view = await entry.fallback?.materialize();
609
+ const current = this.entries.get(key);
610
+ if (!current) return null;
611
+ if (!view || view.data.byteLength === 0) return null;
612
+ if (view.format !== "rgb" && view.format !== "jpeg") return null;
613
+ current.frame = view;
614
+ current.fallback = void 0;
615
+ this.bytes -= current.reservedBytes;
616
+ current.reservedBytes = view.data.byteLength;
617
+ this.bytes += current.reservedBytes;
618
+ this.evictToBudget();
619
+ return view;
620
+ }
621
+ mintHandle(deviceId, frame) {
622
+ const seq = ++this.seq;
623
+ const pixelFree = frame.data.byteLength === 0;
624
+ return {
625
+ shmId: `${RETAINED_FRAME_KEY_PREFIX}${this.nodeId}:${deviceId}`,
626
+ slot: seq % RetainedFrameStore.SLOT_SPREAD,
627
+ seq,
628
+ width: frame.width,
629
+ height: frame.height,
630
+ format: "rgb",
631
+ pts: performance.now(),
632
+ byteLength: pixelFree ? 0 : frame.data.byteLength,
633
+ nodeId: this.nodeId,
634
+ slotCount: RetainedFrameStore.SLOT_SPREAD
635
+ };
636
+ }
637
+ insert(handle, entry) {
638
+ const key = keyOf(handle);
639
+ this.entries.set(key, entry);
640
+ const deviceKeys = this.perDeviceKeys.get(entry.deviceId);
641
+ if (deviceKeys) deviceKeys.push(key);
642
+ else this.perDeviceKeys.set(entry.deviceId, [key]);
643
+ this.bytes += entry.reservedBytes;
644
+ this.evictToBudget();
645
+ }
476
646
  /** Remove one frame by key, keeping the byte tally + per-device index exact. */
477
- dropKey(key) {
647
+ dropKey(key, reason = "drop") {
478
648
  const entry = this.entries.get(key);
479
649
  if (!entry) return;
480
650
  this.entries.delete(key);
481
- this.bytes -= entry.frame.data.byteLength;
651
+ entry.fallback?.release?.();
652
+ this.bytes -= entry.reservedBytes;
482
653
  const deviceKeys = this.perDeviceKeys.get(entry.deviceId);
483
654
  if (deviceKeys) {
484
655
  const idx = deviceKeys.indexOf(key);
485
656
  if (idx >= 0) deviceKeys.splice(idx, 1);
486
657
  if (deviceKeys.length === 0) this.perDeviceKeys.delete(entry.deviceId);
487
658
  }
659
+ if (reason === "evict") this.onEvict?.(entry.deviceId);
488
660
  }
489
661
  };
490
662
  /**
@@ -761,7 +933,8 @@ var SessionDecodeSession = class {
761
933
  this.settleNativeCrop(raw.requestId, {
762
934
  bytes: raw.bytes,
763
935
  width: raw.width,
764
- height: raw.height
936
+ height: raw.height,
937
+ ...raw.source ? { source: raw.source } : {}
765
938
  });
766
939
  return;
767
940
  case "nativeCropMiss":
@@ -1021,26 +1194,6 @@ function createSessionDecodeCoordinator(deps) {
1021
1194
  //#endregion
1022
1195
  //#region src/session-decode/session-decode-pump.ts
1023
1196
  /**
1024
- * Detection working width cap — mirrors the shm decoder's
1025
- * `initScaler` (`nodeav-decoder-session.ts`: `maxW = floor(640 / scale)`,
1026
- * `scale = 1` default). The worker decodes full source resolution (e.g. a 4K
1027
- * `native:main` stream); without this downscale `toBuffer` would ship a full
1028
- * 3840×2160 rgb frame (~24 MB) over IPC per pull, which the detection path
1029
- * cannot keep up with. Aspect ratio is preserved. A source already ≤ this
1030
- * width (e.g. the small `native:sub` motion stream) is passed through
1031
- * unscaled, exactly as the shm scaler's `min(srcW, maxW)` no-ops.
1032
- */
1033
- var WORKING_MAX_WIDTH = 640;
1034
- /** Aspect-preserving cap to {@link WORKING_MAX_WIDTH}; `undefined` when no downscale is needed. */
1035
- function resolveWorkingResize(width, height) {
1036
- if (width <= WORKING_MAX_WIDTH || width <= 0) return void 0;
1037
- const outWidth = WORKING_MAX_WIDTH;
1038
- return {
1039
- width: outWidth,
1040
- height: Math.round(outWidth * height / width)
1041
- };
1042
- }
1043
- /**
1044
1197
  * Build a {@link DecodedFrame} from one pulled {@link VideoFrame}. Downscales to
1045
1198
  * the detection working width (see {@link resolveWorkingResize}) so the ROI
1046
1199
  * scaler ships a small frame instead of the full source resolution.
@@ -1049,17 +1202,18 @@ function resolveWorkingResize(width, height) {
1049
1202
  * time) — this is what lets Step 5 MEASURE session-decode frameAge vs the
1050
1203
  * shm-ring cameras.
1051
1204
  */
1052
- async function toDecodedFrame(frame, format) {
1053
- const resize = resolveWorkingResize(frame.image.width, frame.image.height);
1054
- const bytes = await frame.image.toBuffer({
1055
- format,
1056
- ...resize ? { resize } : {}
1057
- });
1205
+ async function toDecodedFrame(frame, format, viewSpec) {
1206
+ const geometry = viewSpec ? resolveFrameViewGeometry(frame.image.width, frame.image.height, viewSpec) : void 0;
1207
+ const bytes = await frame.image.toBuffer(geometry ? {
1208
+ crop: geometry.sourceCrop,
1209
+ resize: geometry.output,
1210
+ format: viewSpec?.format ?? format
1211
+ } : { format });
1058
1212
  return {
1059
1213
  data: Buffer.from(bytes),
1060
- width: resize?.width ?? frame.image.width,
1061
- height: resize?.height ?? frame.image.height,
1062
- format,
1214
+ width: geometry?.output.width ?? frame.image.width,
1215
+ height: geometry?.output.height ?? frame.image.height,
1216
+ format: viewSpec?.format ?? format,
1063
1217
  timestamp: frame.timestamp,
1064
1218
  capturedAt: Date.now()
1065
1219
  };
@@ -1116,9 +1270,9 @@ function startSessionDecodePump(deps) {
1116
1270
  frame.image.close();
1117
1271
  break;
1118
1272
  }
1273
+ let imageOwnershipTransferred = false;
1119
1274
  try {
1120
1275
  if (deps.shouldMaterializeFrame && !deps.shouldMaterializeFrame()) continue;
1121
- const decoded = await toDecodedFrame(frame, format);
1122
1276
  const image = frame.image;
1123
1277
  const cropFn = image.nativeCrop;
1124
1278
  const nativeCrop = cropFn ? Object.assign((bbox, maxWidth) => cropFn(bbox, maxWidth), {
@@ -1126,7 +1280,33 @@ function startSessionDecodePump(deps) {
1126
1280
  release: () => image.releaseNativeLease?.(),
1127
1281
  markInferred: () => image.markInferred?.()
1128
1282
  }) : void 0;
1129
- deps.onDecodedFrame(decoded, nativeCrop);
1283
+ if (deps.frameRegistry && deps.onFrameRef) {
1284
+ const capturedAt = Date.now();
1285
+ const ref = deps.frameRegistry.register(image, {
1286
+ timestamp: frame.timestamp,
1287
+ capturedAt
1288
+ });
1289
+ imageOwnershipTransferred = true;
1290
+ if (!ref) continue;
1291
+ const lazyFrame = {
1292
+ data: Buffer.alloc(0),
1293
+ width: ref.width,
1294
+ height: ref.height,
1295
+ format: ref.format,
1296
+ timestamp: ref.timestamp,
1297
+ capturedAt,
1298
+ frameRef: ref
1299
+ };
1300
+ try {
1301
+ if (!await deps.onFrameRef(lazyFrame, nativeCrop)) deps.frameRegistry.release(ref, "drop");
1302
+ } catch (err) {
1303
+ deps.frameRegistry.release(ref, "error");
1304
+ throw err;
1305
+ }
1306
+ } else {
1307
+ const decoded = await toDecodedFrame(frame, format, deps.inlineViewSpec);
1308
+ deps.onDecodedFrame(decoded, nativeCrop);
1309
+ }
1130
1310
  } catch (err) {
1131
1311
  deps.logger.debug("session-decode pump: skipped a frame (decode/consume error)", {
1132
1312
  tags: { deviceId: deps.source.deviceId },
@@ -1136,7 +1316,7 @@ function startSessionDecodePump(deps) {
1136
1316
  }
1137
1317
  });
1138
1318
  } finally {
1139
- frame.image.close();
1319
+ if (!imageOwnershipTransferred) frame.image.close();
1140
1320
  }
1141
1321
  }
1142
1322
  } catch (err) {
@@ -1526,6 +1706,424 @@ function buildBenchRunPipelineInput(req) {
1526
1706
  };
1527
1707
  }
1528
1708
  //#endregion
1709
+ //#region src/pipeline-runner/frame-lazy-metrics.ts
1710
+ function emptyFrameLazyCounters() {
1711
+ return {
1712
+ framesDecoded: 0,
1713
+ framesAdmitted: 0,
1714
+ framesDroppedPixelFree: 0,
1715
+ viewsMaterialized: 0,
1716
+ viewsSkipped: 0,
1717
+ workerToRunnerBytes: 0,
1718
+ runnerToPoolRawBytes: 0,
1719
+ runnerToPoolJpegBytes: 0,
1720
+ onDemandFullFrameRequests: 0,
1721
+ onDemandCropRequests: 0,
1722
+ nativeHits: 0,
1723
+ nativeMisses: 0,
1724
+ tileHits: 0,
1725
+ tileMisses: 0,
1726
+ fallbackHits: 0,
1727
+ fallbackMisses: 0,
1728
+ retainedWritesAvoided: 0,
1729
+ residentRefs: 0,
1730
+ residentBytes: 0,
1731
+ releases: 0,
1732
+ evictions: 0,
1733
+ staleMisses: 0
1734
+ };
1735
+ }
1736
+ var FrameLazyTelemetry = class {
1737
+ node = emptyFrameLazyCounters();
1738
+ cameras = /* @__PURE__ */ new Map();
1739
+ noteDecoded(deviceId) {
1740
+ this.node.framesDecoded += 1;
1741
+ this.camera(deviceId).framesDecoded += 1;
1742
+ }
1743
+ noteAdmitted(deviceId) {
1744
+ this.node.framesAdmitted += 1;
1745
+ this.camera(deviceId).framesAdmitted += 1;
1746
+ }
1747
+ noteDroppedPixelFree(deviceId) {
1748
+ this.node.framesDroppedPixelFree += 1;
1749
+ this.camera(deviceId).framesDroppedPixelFree += 1;
1750
+ }
1751
+ noteView(input) {
1752
+ if (input.materialized) {
1753
+ this.node.viewsMaterialized += 1;
1754
+ this.camera(input.deviceId).viewsMaterialized += 1;
1755
+ } else {
1756
+ this.node.viewsSkipped += 1;
1757
+ this.camera(input.deviceId).viewsSkipped += 1;
1758
+ }
1759
+ this.node.workerToRunnerBytes += input.workerToRunnerBytes;
1760
+ this.camera(input.deviceId).workerToRunnerBytes += input.workerToRunnerBytes;
1761
+ }
1762
+ notePoolBytes(input) {
1763
+ this.node.runnerToPoolRawBytes += input.raw;
1764
+ this.node.runnerToPoolJpegBytes += input.jpeg;
1765
+ this.camera(input.deviceId).runnerToPoolRawBytes += input.raw;
1766
+ this.camera(input.deviceId).runnerToPoolJpegBytes += input.jpeg;
1767
+ }
1768
+ noteOnDemandRequest(input) {
1769
+ if (input.kind === "full-frame") {
1770
+ this.node.onDemandFullFrameRequests += 1;
1771
+ this.camera(input.deviceId).onDemandFullFrameRequests += 1;
1772
+ } else {
1773
+ this.node.onDemandCropRequests += 1;
1774
+ this.camera(input.deviceId).onDemandCropRequests += 1;
1775
+ }
1776
+ }
1777
+ noteHit(kind, deviceId) {
1778
+ this.addHit(this.node, kind, 1);
1779
+ this.addHit(this.camera(deviceId), kind, 1);
1780
+ }
1781
+ noteMiss(kind, deviceId) {
1782
+ this.addMiss(this.node, kind, 1);
1783
+ this.addMiss(this.camera(deviceId), kind, 1);
1784
+ }
1785
+ noteAvoidedWrite(deviceId) {
1786
+ this.node.retainedWritesAvoided += 1;
1787
+ this.camera(deviceId).retainedWritesAvoided += 1;
1788
+ }
1789
+ noteRelease(deviceId) {
1790
+ this.node.releases += 1;
1791
+ if (deviceId !== void 0) this.camera(deviceId).releases += 1;
1792
+ }
1793
+ noteEviction(deviceId) {
1794
+ this.node.evictions += 1;
1795
+ if (deviceId !== void 0) this.camera(deviceId).evictions += 1;
1796
+ }
1797
+ noteStale(deviceId) {
1798
+ this.node.staleMisses += 1;
1799
+ if (deviceId !== void 0) this.camera(deviceId).staleMisses += 1;
1800
+ }
1801
+ /**
1802
+ * Node occupancy. `residentRefs` is entry count (pending + store), not
1803
+ * outstanding registry leases.
1804
+ */
1805
+ setResident(input) {
1806
+ this.node.residentRefs = input.refs;
1807
+ this.node.residentBytes = input.bytes;
1808
+ }
1809
+ setCameraResident(deviceId, input) {
1810
+ const counters = this.camera(deviceId);
1811
+ counters.residentRefs = input.refs;
1812
+ counters.residentBytes = input.bytes;
1813
+ }
1814
+ forgetCamera(deviceId) {
1815
+ this.cameras.delete(deviceId);
1816
+ }
1817
+ /**
1818
+ * Zero occupancy for already-tracked cameras absent from `present`.
1819
+ * Does not create or delete entries — snapshot still does not prune,
1820
+ * and detach still goes through {@link forgetCamera}.
1821
+ */
1822
+ zeroAbsentResidents(present) {
1823
+ for (const [deviceId, counters] of this.cameras) {
1824
+ if (present.has(deviceId)) continue;
1825
+ counters.residentRefs = 0;
1826
+ counters.residentBytes = 0;
1827
+ }
1828
+ }
1829
+ snapshot(deviceIds) {
1830
+ return {
1831
+ node: { ...this.node },
1832
+ cameras: deviceIds.map((deviceId) => ({
1833
+ deviceId,
1834
+ ...this.camera(deviceId)
1835
+ }))
1836
+ };
1837
+ }
1838
+ trackedCameraCount() {
1839
+ return this.cameras.size;
1840
+ }
1841
+ camera(deviceId) {
1842
+ const existing = this.cameras.get(deviceId);
1843
+ if (existing) return existing;
1844
+ const created = emptyFrameLazyCounters();
1845
+ this.cameras.set(deviceId, created);
1846
+ return created;
1847
+ }
1848
+ addHit(counters, kind, n) {
1849
+ if (kind === "native") counters.nativeHits += n;
1850
+ else if (kind === "tile") counters.tileHits += n;
1851
+ else counters.fallbackHits += n;
1852
+ }
1853
+ addMiss(counters, kind, n) {
1854
+ if (kind === "native") counters.nativeMisses += n;
1855
+ else if (kind === "tile") counters.tileMisses += n;
1856
+ else counters.fallbackMisses += n;
1857
+ }
1858
+ };
1859
+ //#endregion
1860
+ //#region src/pipeline-runner/frame-ref-transport.ts
1861
+ /** A local ref never leaves the grouped process; inline remains rollback/split compatibility. */
1862
+ function selectExecutorFrameSource(coLocated, frameRef, inlineFrame) {
1863
+ return coLocated && frameRef ? { frameRef } : { frame: inlineFrame };
1864
+ }
1865
+ //#endregion
1866
+ //#region src/session-decode/on-demand-jpeg-fallback.ts
1867
+ /** Single content-sized JPEG requested after a detection, never a continuous raw. */
1868
+ var ON_DEMAND_JPEG_VIEW = {
1869
+ content: {
1870
+ width: 640,
1871
+ height: 640
1872
+ },
1873
+ fit: "contain",
1874
+ format: "jpeg"
1875
+ };
1876
+ function createOnDemandJpegFallback(registry, ref) {
1877
+ const lease = registry.acquire(ref);
1878
+ let cached;
1879
+ let released = false;
1880
+ const releaseLease = (reason) => {
1881
+ if (released) return;
1882
+ released = true;
1883
+ lease?.release(reason);
1884
+ };
1885
+ return {
1886
+ async materialize() {
1887
+ if (cached !== void 0) return cached;
1888
+ if (!lease) {
1889
+ cached = null;
1890
+ return null;
1891
+ }
1892
+ try {
1893
+ const view = await lease.resolve(ON_DEMAND_JPEG_VIEW);
1894
+ if (view.kind !== "view" || view.format !== "jpeg" || view.data.byteLength === 0) {
1895
+ cached = null;
1896
+ releaseLease("drop");
1897
+ return null;
1898
+ }
1899
+ cached = {
1900
+ data: Buffer.from(view.data.buffer, view.data.byteOffset, view.data.byteLength),
1901
+ width: view.width,
1902
+ height: view.height,
1903
+ format: "jpeg",
1904
+ timestamp: ref.timestamp,
1905
+ ...ref.capturedAt !== void 0 ? { capturedAt: ref.capturedAt } : {}
1906
+ };
1907
+ releaseLease("success");
1908
+ return cached;
1909
+ } catch {
1910
+ cached = null;
1911
+ releaseLease("error");
1912
+ return null;
1913
+ }
1914
+ },
1915
+ release() {
1916
+ releaseLease("eviction");
1917
+ }
1918
+ };
1919
+ }
1920
+ //#endregion
1921
+ //#region src/pipeline-runner/on-demand-retention.ts
1922
+ var CAMSTACK_CONTINUOUS_RETAIN = "CAMSTACK_CONTINUOUS_RETAIN";
1923
+ function isContinuousRetainEnabled(env = process.env) {
1924
+ return env[CAMSTACK_CONTINUOUS_RETAIN] === "1";
1925
+ }
1926
+ function shouldRetainDetectionFrame(input) {
1927
+ if (!input.admitted) return false;
1928
+ if (input.continuousRetain) return true;
1929
+ return input.hasDetection;
1930
+ }
1931
+ function encodeCrossNodeCrop(input) {
1932
+ if (!input.jpeg) return null;
1933
+ return { jpeg: input.jpeg };
1934
+ }
1935
+ function handleKey(handle) {
1936
+ return `${handle.shmId}#${handle.slot}#${handle.seq}`;
1937
+ }
1938
+ /**
1939
+ * Pending occupancy exists only admission → inference result. A detection
1940
+ * transfers to {@link RetainedFrameStore} (authoritative late-media owner).
1941
+ * No-detection leaves zero state. Store eviction owns fallback cleanup.
1942
+ */
1943
+ var OnDemandRetentionSession = class {
1944
+ store;
1945
+ registry;
1946
+ telemetry;
1947
+ continuousRetain;
1948
+ pending = /* @__PURE__ */ new Map();
1949
+ constructor(options) {
1950
+ this.store = options.store;
1951
+ this.registry = options.registry;
1952
+ this.telemetry = options.telemetry;
1953
+ this.continuousRetain = options.continuousRetain;
1954
+ }
1955
+ get pendingSize() {
1956
+ return this.pending.size;
1957
+ }
1958
+ hasPending(handle) {
1959
+ return this.pending.has(handleKey(handle));
1960
+ }
1961
+ admitFrame(deviceId, frame) {
1962
+ if (this.continuousRetain) {
1963
+ const fallback = frame.frameRef ? createRetainedContentFallback(this.registry, frame.frameRef) : void 0;
1964
+ const handle = this.store.write(deviceId, frame, fallback) ?? void 0;
1965
+ if (handle) this.pending.set(handleKey(handle), {
1966
+ deviceId,
1967
+ ref: frame.frameRef
1968
+ });
1969
+ this.refreshResident();
1970
+ return handle;
1971
+ }
1972
+ const handle = this.store.allocateHandle(deviceId, frame) ?? void 0;
1973
+ if (!handle) return void 0;
1974
+ this.pending.set(handleKey(handle), {
1975
+ deviceId,
1976
+ ref: frame.frameRef
1977
+ });
1978
+ this.telemetry.noteAvoidedWrite(deviceId);
1979
+ this.refreshResident();
1980
+ return handle;
1981
+ }
1982
+ noteFrame(input) {
1983
+ if (input.decoded) this.telemetry.noteDecoded(input.deviceId);
1984
+ if (input.admitted) this.telemetry.noteAdmitted(input.deviceId);
1985
+ if (input.droppedPixelFree) this.telemetry.noteDroppedPixelFree(input.deviceId);
1986
+ }
1987
+ noteInferenceResult(deviceId, handle, result) {
1988
+ if (!handle) return;
1989
+ const key = handleKey(handle);
1990
+ const pending = this.pending.get(key);
1991
+ this.forgetPending(key);
1992
+ if (!shouldRetainDetectionFrame({
1993
+ admitted: true,
1994
+ hasDetection: (result.detections?.length ?? 0) > 0,
1995
+ continuousRetain: this.continuousRetain
1996
+ })) {
1997
+ this.refreshResident();
1998
+ return;
1999
+ }
2000
+ if (this.continuousRetain) {
2001
+ this.refreshResident();
2002
+ return;
2003
+ }
2004
+ if (!pending?.ref) {
2005
+ this.refreshResident();
2006
+ return;
2007
+ }
2008
+ const fallback = createOnDemandJpegFallback(this.registry, pending.ref);
2009
+ this.store.rememberOnDemand(deviceId, handle, fallback);
2010
+ this.refreshResident();
2011
+ }
2012
+ async requestMedia(handle, request) {
2013
+ const deviceId = this.store.getDeviceId(handle) ?? this.pending.get(handleKey(handle))?.deviceId ?? 0;
2014
+ const kind = request.kind === "full-frame" ? "full-frame" : "crop";
2015
+ this.telemetry.noteOnDemandRequest({
2016
+ deviceId,
2017
+ kind
2018
+ });
2019
+ if (request.nativeHit) {
2020
+ this.telemetry.noteHit("native", deviceId);
2021
+ return {
2022
+ tier: "native",
2023
+ bytes: request.nativeHit.bytes,
2024
+ width: request.nativeHit.width,
2025
+ height: request.nativeHit.height
2026
+ };
2027
+ }
2028
+ this.telemetry.noteMiss("native", deviceId);
2029
+ if (request.tileHit) {
2030
+ this.telemetry.noteHit("tile", deviceId);
2031
+ return {
2032
+ tier: "tile",
2033
+ bytes: request.tileHit.bytes,
2034
+ jpeg: request.tileHit.jpeg ? request.tileHit.bytes : void 0,
2035
+ width: request.tileHit.width,
2036
+ height: request.tileHit.height
2037
+ };
2038
+ }
2039
+ this.telemetry.noteMiss("tile", deviceId);
2040
+ if (request.recordedStillHit) return {
2041
+ tier: "recorded-still",
2042
+ jpeg: request.recordedStillHit.jpeg,
2043
+ width: request.recordedStillHit.width,
2044
+ height: request.recordedStillHit.height
2045
+ };
2046
+ if (!this.continuousRetain && !this.store.has(handle)) {
2047
+ this.telemetry.noteMiss("fallback", deviceId);
2048
+ return { tier: "miss" };
2049
+ }
2050
+ const view = await this.store.ensureContent(handle);
2051
+ if (view && view.data.byteLength > 0) {
2052
+ this.telemetry.noteHit("fallback", deviceId);
2053
+ this.refreshResident();
2054
+ if (view.format === "jpeg") return {
2055
+ tier: "fallback-jpeg",
2056
+ jpeg: view.data,
2057
+ width: view.width,
2058
+ height: view.height
2059
+ };
2060
+ return {
2061
+ tier: "fallback-jpeg",
2062
+ bytes: view.data,
2063
+ width: view.width,
2064
+ height: view.height
2065
+ };
2066
+ }
2067
+ this.telemetry.noteMiss("fallback", deviceId);
2068
+ this.telemetry.noteStale(deviceId);
2069
+ return { tier: "miss" };
2070
+ }
2071
+ /**
2072
+ * Pending cleanup. Success never invalidates a store entry already
2073
+ * transferred for late pull. Drop/error/detach drop the store only when the
2074
+ * handle was still pending (not yet transferred). Eviction owns transferred
2075
+ * fallback cleanup.
2076
+ */
2077
+ release(handle, reason) {
2078
+ const key = handleKey(handle);
2079
+ const pending = this.pending.get(key);
2080
+ this.forgetPending(key);
2081
+ if (reason !== "success" && pending) this.store.drop(handle);
2082
+ this.telemetry.noteRelease(pending?.deviceId ?? this.store.getDeviceId(handle));
2083
+ this.refreshResident();
2084
+ }
2085
+ forgetDevice(deviceId) {
2086
+ for (const [key, pending] of [...this.pending]) {
2087
+ if (pending.deviceId !== deviceId) continue;
2088
+ this.pending.delete(key);
2089
+ }
2090
+ this.telemetry.forgetCamera(deviceId);
2091
+ this.refreshResident();
2092
+ }
2093
+ syncResident() {
2094
+ this.refreshResident();
2095
+ }
2096
+ forgetPending(key) {
2097
+ this.pending.delete(key);
2098
+ }
2099
+ refreshResident() {
2100
+ const byDevice = /* @__PURE__ */ new Map();
2101
+ for (const pending of this.pending.values()) {
2102
+ const cur = byDevice.get(pending.deviceId) ?? {
2103
+ refs: 0,
2104
+ bytes: 0
2105
+ };
2106
+ cur.refs += 1;
2107
+ byDevice.set(pending.deviceId, cur);
2108
+ }
2109
+ for (const stats of this.store.deviceStats()) {
2110
+ const cur = byDevice.get(stats.deviceId) ?? {
2111
+ refs: 0,
2112
+ bytes: 0
2113
+ };
2114
+ cur.refs += stats.entries;
2115
+ cur.bytes += stats.bytes;
2116
+ byDevice.set(stats.deviceId, cur);
2117
+ }
2118
+ this.telemetry.setResident({
2119
+ refs: this.pending.size + this.store.size,
2120
+ bytes: this.registry.estimatedBytes + this.store.retainedBytes
2121
+ });
2122
+ this.telemetry.zeroAbsentResidents(new Set(byDevice.keys()));
2123
+ for (const [deviceId, stats] of byDevice) this.telemetry.setCameraResident(deviceId, stats);
2124
+ }
2125
+ };
2126
+ //#endregion
1529
2127
  //#region src/pipeline-runner/capacity/capacity-runner.ts
1530
2128
  /**
1531
2129
  * Run ONE ramp step: `cameras` independent open-loop pacers for
@@ -2435,6 +3033,35 @@ function summarizeRunnerHardware(hardware, cpuCores = os.cpus().length) {
2435
3033
  };
2436
3034
  }
2437
3035
  //#endregion
3036
+ //#region src/pipeline-runner/motion-frame-view.ts
3037
+ var MOTION_FRAME_VIEW = {
3038
+ content: {
3039
+ width: 320,
3040
+ height: 320
3041
+ },
3042
+ fit: "contain",
3043
+ format: "gray"
3044
+ };
3045
+ /**
3046
+ * Materialize the motion gray view under a short-lived registry lease.
3047
+ * Release is idempotent and always runs, including miss/error.
3048
+ */
3049
+ async function resolveMotionFrameView(registry, ref) {
3050
+ const lease = registry.acquire(ref);
3051
+ if (!lease) return {
3052
+ kind: "miss",
3053
+ reason: ref.registryId === registry.registryId ? "stale-ref" : "foreign-registry"
3054
+ };
3055
+ let reason = "error";
3056
+ try {
3057
+ const view = await lease.resolve(MOTION_FRAME_VIEW);
3058
+ reason = view.kind === "view" ? "success" : "drop";
3059
+ return view;
3060
+ } finally {
3061
+ lease.release(reason);
3062
+ }
3063
+ }
3064
+ //#endregion
2438
3065
  //#region src/pipeline-runner/motion-settle-governor.ts
2439
3066
  /**
2440
3067
  * MotionSettleGovernor — decides WHEN the root-plane `package-detection` step
@@ -2697,6 +3324,13 @@ function resolveNativeCropSource(isFullFrame, nativeHit) {
2697
3324
  if (nativeHit) return "native";
2698
3325
  return isFullFrame ? "ram-fullframe" : "miss";
2699
3326
  }
3327
+ /**
3328
+ * Runtime hit kind for telemetry. A worker tile is native-resolution quality
3329
+ * (public wire `tier` stays `native`) but must not increment `nativeHits`.
3330
+ */
3331
+ function resolveNativeCropHitKind(source) {
3332
+ return source === "tile" ? "tile" : "native";
3333
+ }
2700
3334
  //#endregion
2701
3335
  //#region src/pipeline-runner/native-lease-settings-source.ts
2702
3336
  /**
@@ -2879,18 +3513,23 @@ var FrameQueue = class {
2879
3513
  latest = null;
2880
3514
  _droppedFrames = 0;
2881
3515
  clone;
3516
+ dispose;
2882
3517
  /**
2883
3518
  * `clone` runs on every {@link enqueue} to detach a queue-owned copy from
2884
3519
  * the (possibly borrowed) source — required by the D9 Task 7b ownership
2885
3520
  * contract above. Motion call sites pass {@link ownFrame}; the detection
2886
3521
  * path passes a clone that copies its inner frame's pixel buffer.
2887
3522
  */
2888
- constructor(maxSize, clone) {
3523
+ constructor(maxSize, clone, dispose) {
2889
3524
  this.maxSize = maxSize;
2890
3525
  this.clone = clone;
3526
+ this.dispose = dispose;
2891
3527
  }
2892
3528
  enqueue(item) {
2893
- if (this.latest !== null) this._droppedFrames++;
3529
+ if (this.latest !== null) {
3530
+ this._droppedFrames++;
3531
+ this.dispose?.(this.latest);
3532
+ }
2894
3533
  this.latest = this.clone(item);
2895
3534
  }
2896
3535
  dequeue() {
@@ -2905,6 +3544,7 @@ var FrameQueue = class {
2905
3544
  return this._droppedFrames;
2906
3545
  }
2907
3546
  clear() {
3547
+ if (this.latest !== null) this.dispose?.(this.latest);
2908
3548
  this.latest = null;
2909
3549
  }
2910
3550
  };
@@ -3307,7 +3947,8 @@ function toFrameInput$1(frame) {
3307
3947
  width: frame.width,
3308
3948
  height: frame.height,
3309
3949
  format: frame.format,
3310
- timestamp: frame.timestamp
3950
+ timestamp: frame.timestamp,
3951
+ ...frame.frameRef ? { frameRef: frame.frameRef } : {}
3311
3952
  };
3312
3953
  }
3313
3954
  /**
@@ -3406,7 +4047,10 @@ var PipelineRunner = class {
3406
4047
  }
3407
4048
  registerCamera(deviceId, registration) {
3408
4049
  const motionQueue = new FrameQueue(this.config.maxQueueDepth, ownMotionEntry);
3409
- const detectionQueue = new FrameQueue(this.config.maxQueueDepth, ownDetectionEntry);
4050
+ const detectionQueue = new FrameQueue(this.config.maxQueueDepth, ownDetectionEntry, (entry) => {
4051
+ if (entry.handle) this.config.releaseRetention?.(entry.handle, "drop");
4052
+ if (entry.frame.frameRef) this.config.releaseFrameRef?.(entry.frame.frameRef, "drop");
4053
+ });
3410
4054
  const initialPhase = registration.detectionMode === "disabled" ? "idle" : registration.detectionMode === "always-on" ? "active" : "watching";
3411
4055
  const state = {
3412
4056
  registration,
@@ -3804,9 +4448,13 @@ var PipelineRunner = class {
3804
4448
  };
3805
4449
  const semAcquiredAt = Date.now();
3806
4450
  const executorHandle = state.detectionQueue.size <= HANDLE_PASSTHROUGH_MAX_QUEUE_DEPTH && this.semaphore.available > 0 ? handle : void 0;
4451
+ let frameReleaseReason = "error";
3807
4452
  try {
3808
4453
  const result = await this.awaitInferenceBounded(deviceId, frameInput, executorHandle, release);
3809
- if (result === TIMED_OUT) return;
4454
+ if (result === TIMED_OUT) {
4455
+ frameReleaseReason = "drop";
4456
+ return;
4457
+ }
3810
4458
  const inferDoneAt = Date.now();
3811
4459
  const inferenceMs = inferDoneAt - semAcquiredAt;
3812
4460
  state.inferenceTimes.push(inferenceMs);
@@ -3815,6 +4463,7 @@ var PipelineRunner = class {
3815
4463
  if (result) {
3816
4464
  if (result.shed === true) state.shedFrames++;
3817
4465
  await this.notifyCallbacks(deviceId, frame, result, streamType, handle);
4466
+ frameReleaseReason = "success";
3818
4467
  const emittedAt = Date.now();
3819
4468
  const capturedAt = frame.capturedAt;
3820
4469
  this.timingSampler.addSample(deviceId, {
@@ -3826,9 +4475,16 @@ var PipelineRunner = class {
3826
4475
  endToEnd: emittedAt - enqueuedAt,
3827
4476
  detections: result.detections?.length ?? 0
3828
4477
  });
3829
- }
4478
+ } else frameReleaseReason = "drop";
4479
+ } catch {
4480
+ frameReleaseReason = "error";
3830
4481
  } finally {
3831
4482
  release();
4483
+ try {
4484
+ if (frameReleaseReason === "success" && handle && isContinuousRetainEnabled()) await this.config.hydrateRetainedFrame?.(handle);
4485
+ } catch {}
4486
+ if (handle) this.config.releaseRetention?.(handle, frameReleaseReason);
4487
+ if (frame.frameRef) this.config.releaseFrameRef?.(frame.frameRef, frameReleaseReason);
3832
4488
  }
3833
4489
  }
3834
4490
  /**
@@ -4178,6 +4834,20 @@ var DEFAULT_CONFIG = {
4178
4834
  * than raw RGB — the hop transits the hub main process (UDS child↔child
4179
4835
  * routing), which OOM'd under raw frames. */
4180
4836
  var DETECTION_HOP_JPEG_QUALITY = 82;
4837
+ var SPLIT_DETECTION_VIEW = {
4838
+ content: {
4839
+ width: 640,
4840
+ height: 640
4841
+ },
4842
+ fit: "contain",
4843
+ format: "rgb"
4844
+ };
4845
+ function writeRetainedDetectionFrame(store, deviceId, frame, retention) {
4846
+ if (retention) return retention.admitFrame(deviceId, frame);
4847
+ if (!store) return void 0;
4848
+ if (!isContinuousRetainEnabled()) return store.allocateHandle(deviceId, frame) ?? void 0;
4849
+ return store.write(deviceId, frame, frame.frameRef ? createRetainedContentFallback(localFrameRegistry, frame.frameRef) : void 0) ?? void 0;
4850
+ }
4181
4851
  var DETECTION_KEEPWARM_MS = 15e3;
4182
4852
  /**
4183
4853
  * Occupancy-burst safety timeout (both the dial path and the analyzer-tap
@@ -4498,6 +5168,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4498
5168
  * Initialised in {@link onInitialize} once `nodeId` is known.
4499
5169
  */
4500
5170
  retainedFrames = null;
5171
+ frameLazy = new FrameLazyTelemetry();
5172
+ retention = null;
4501
5173
  attached = /* @__PURE__ */ new Map();
4502
5174
  /**
4503
5175
  * Detection decode workers parked across a detach, keyed by deviceId — see
@@ -4676,7 +5348,15 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4676
5348
  this.nodeId = raw.includes("/") ? raw.split("/")[0] : raw;
4677
5349
  this.retainedFrames = new RetainedFrameStore({
4678
5350
  nodeId: this.nodeId,
4679
- logger: this.ctx.logger.child("retained-frames")
5351
+ logger: this.ctx.logger.child("retained-frames"),
5352
+ onEvict: (deviceId) => this.frameLazy.noteEviction(deviceId)
5353
+ });
5354
+ this.retention = new OnDemandRetentionSession({
5355
+ store: this.retainedFrames,
5356
+ registry: localFrameRegistry,
5357
+ telemetry: this.frameLazy,
5358
+ nodeId: this.nodeId,
5359
+ continuousRetain: isContinuousRetainEnabled()
4680
5360
  });
4681
5361
  this.runner = new PipelineRunner({
4682
5362
  maxQueueDepth: this.config.maxQueueDepth,
@@ -4685,6 +5365,9 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4685
5365
  minThrottledFps: this.config.minThrottledFps,
4686
5366
  processFrame: (deviceId, frame, handle) => this.runInference(deviceId, frame, handle),
4687
5367
  analyzeMotion: (deviceId, frame, handle) => this.runMotionAnalysis(deviceId, frame, handle),
5368
+ releaseFrameRef: (ref, reason) => localFrameRegistry.release(ref, reason),
5369
+ releaseRetention: (handle, reason) => this.retention?.release(handle, reason),
5370
+ hydrateRetainedFrame: (handle) => this.retainedFrames?.ensureContent(handle) ?? Promise.resolve(null),
4688
5371
  onPhaseChanged: (deviceId, phase, meta) => this.handlePhaseChanged(deviceId, phase, meta),
4689
5372
  logger: this.ctx.logger,
4690
5373
  onOccupancyRecheck: (deviceId, frames) => {
@@ -4701,6 +5384,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4701
5384
  this.handleDetectionStreamChange(deviceId, action);
4702
5385
  });
4703
5386
  this.runner.onResult(async (deviceId, frame, result, _streamType, handle) => {
5387
+ this.retention?.noteInferenceResult(deviceId, handle, result);
4704
5388
  this.cutSubjectTiles(deviceId, result, handle);
4705
5389
  this.emitInferenceResult(deviceId, frame, result, handle);
4706
5390
  const detectionCount = result.detections?.length ?? 0;
@@ -4796,6 +5480,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4796
5480
  this.nativeCropRegistry.clear();
4797
5481
  this.retainedFrames?.clear();
4798
5482
  this.retainedFrames = null;
5483
+ this.retention = null;
4799
5484
  }
4800
5485
  async cacheBenchFrame(input) {
4801
5486
  const { data, info } = await (await getSharp())(Buffer.from(input.imageBase64, "base64")).raw().toBuffer({ resolveWithObject: true });
@@ -5548,11 +6233,22 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5548
6233
  * resolve legitimately want the lower-RES honest frame).
5549
6234
  */
5550
6235
  async getNativeCrop(input) {
6236
+ const deviceId = this.nativeCropRegistry.deviceIdFor(input.handle) ?? 0;
6237
+ const isFullFrame = input.bbox.x <= .001 && input.bbox.y <= .001 && input.bbox.w >= .999 && input.bbox.h >= .999;
6238
+ this.frameLazy.noteOnDemandRequest({
6239
+ deviceId,
6240
+ kind: isFullFrame && input.maxWidth === void 0 ? "full-frame" : "crop"
6241
+ });
5551
6242
  const resolved = await this.resolveNativeCropPixels(input);
5552
- if (resolved.tier === "native" && resolved.pixels !== null) return this.encodeNativeCropResult(resolved.pixels, resolved.tier, input.encodeJpeg);
6243
+ if (resolved.tier === "native" && resolved.pixels !== null) {
6244
+ this.frameLazy.noteHit(resolveNativeCropHitKind(resolved.pixels.source), deviceId);
6245
+ return this.encodeNativeCropResult(resolved.pixels, resolved.tier, input.encodeJpeg);
6246
+ }
6247
+ this.frameLazy.noteMiss("native", deviceId);
5553
6248
  if (resolved.tier !== "ram-fullframe") return null;
5554
- const crop = this.retainedFrames?.crop(input.handle, input.bbox, input.maxWidth);
6249
+ const crop = await this.retainedFrames?.cropAsync(input.handle, input.bbox, input.maxWidth);
5555
6250
  if (!crop) {
6251
+ this.frameLazy.noteMiss("fallback", deviceId);
5556
6252
  this.ctx?.logger.warn("native full-frame miss — the retained store no longer holds this frame", {
5557
6253
  tags: { deviceId: this.nativeCropRegistry.deviceIdFor(input.handle) ?? -1 },
5558
6254
  meta: {
@@ -5566,6 +6262,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5566
6262
  });
5567
6263
  return null;
5568
6264
  }
6265
+ this.frameLazy.noteHit("fallback", deviceId);
5569
6266
  return this.encodeNativeCropResult({
5570
6267
  bytes: crop.bytes,
5571
6268
  width: crop.width,
@@ -5588,6 +6285,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5588
6285
  */
5589
6286
  cutSubjectTiles(deviceId, result, handle) {
5590
6287
  const outcome = this.dispatchSubjectTiles(result, handle);
6288
+ if ((result.detections?.length ?? 0) > 0) if (outcome === "sent") this.frameLazy.noteHit("tile", deviceId);
6289
+ else this.frameLazy.noteMiss("tile", deviceId);
5591
6290
  if (outcome === "sent") return;
5592
6291
  const tally = this.tileCutDrops.get(deviceId) ?? {
5593
6292
  handleless: 0,
@@ -5689,7 +6388,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5689
6388
  if (result) nativeResult = {
5690
6389
  bytes: result.bytes,
5691
6390
  width: result.width,
5692
- height: result.height
6391
+ height: result.height,
6392
+ ...result.source ? { source: result.source } : {}
5693
6393
  };
5694
6394
  else missReason = "worker-lease-gone";
5695
6395
  } else missReason = "no-registry-entry";
@@ -5728,6 +6428,16 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5728
6428
  * (`null`) — the caller retries — never a silent raw-RGB cross-node fallback.
5729
6429
  */
5730
6430
  async encodeNativeCropResult(crop, tier, encodeJpeg) {
6431
+ if (crop.bytes.byteLength >= 2 && crop.bytes[0] === 255 && crop.bytes[1] === 216) {
6432
+ const encoded = encodeCrossNodeCrop({ jpeg: Buffer.from(crop.bytes).toString("base64") });
6433
+ if (!encoded) return null;
6434
+ return {
6435
+ jpeg: encoded.jpeg,
6436
+ width: crop.width,
6437
+ height: crop.height,
6438
+ tier
6439
+ };
6440
+ }
5731
6441
  if (encodeJpeg !== true) return {
5732
6442
  bytes: crop.bytes,
5733
6443
  width: crop.width,
@@ -5735,12 +6445,17 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5735
6445
  tier
5736
6446
  };
5737
6447
  try {
5738
- return {
6448
+ const encoded = encodeCrossNodeCrop({
5739
6449
  jpeg: (await (await getSharp())(Buffer.from(crop.bytes), { raw: {
5740
6450
  width: crop.width,
5741
6451
  height: crop.height,
5742
6452
  channels: 3
5743
6453
  } }).jpeg({ quality: 90 }).toBuffer()).toString("base64"),
6454
+ bytes: crop.bytes
6455
+ });
6456
+ if (!encoded) return null;
6457
+ return {
6458
+ jpeg: encoded.jpeg,
5744
6459
  width: crop.width,
5745
6460
  height: crop.height,
5746
6461
  tier
@@ -6081,6 +6796,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6081
6796
  this.attached.delete(deviceId);
6082
6797
  this.lastMotionAt.delete(deviceId);
6083
6798
  this.lastEmittedCameraMetrics.delete(deviceId);
6799
+ this.retention?.forgetDevice(deviceId);
6084
6800
  this.runner?.unregisterCamera(deviceId);
6085
6801
  this.ctx?.logger.info("detachCamera", {
6086
6802
  tags: { deviceId },
@@ -6275,9 +6991,11 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6275
6991
  avgInferenceTimeMs: 0,
6276
6992
  queueDepth: 0
6277
6993
  };
6994
+ this.retention?.syncResident();
6278
6995
  return {
6279
6996
  nodeId: this.nodeId,
6280
- ...m
6997
+ ...m,
6998
+ frameLazy: this.frameLazy.snapshot(this.getLocalCameras())
6281
6999
  };
6282
7000
  }
6283
7001
  async getCameraMetrics(input) {
@@ -6437,9 +7155,11 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6437
7155
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:detect:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config), true),
6438
7156
  startPump: (source) => {
6439
7157
  const dialMs = Date.now() - requestedAt;
7158
+ const useLocalFrameRef = this.executorCoLocated() && process.env["CAMSTACK_DISABLE_LOCAL_FRAME_REF"] !== "1";
6440
7159
  log.info("session-decode: detection routed through per-session decode worker (rgb)", { meta: {
6441
7160
  streamId: config.detectionStreamId,
6442
- codec: source.codec
7161
+ codec: source.codec,
7162
+ transport: useLocalFrameRef ? "local-frame-ref" : "inline-compat"
6443
7163
  } });
6444
7164
  let reservation = null;
6445
7165
  return startSessionDecodePump({
@@ -6452,8 +7172,52 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6452
7172
  },
6453
7173
  shouldMaterializeFrame: () => {
6454
7174
  reservation = this.reserveSharedDetectionFrame(config.deviceId);
6455
- return reservation.detection || reservation.motion;
7175
+ const materialize = reservation.detection || reservation.motion;
7176
+ this.retention?.noteFrame({
7177
+ deviceId: config.deviceId,
7178
+ decoded: true,
7179
+ admitted: reservation.detection,
7180
+ droppedPixelFree: !materialize
7181
+ });
7182
+ if (!materialize) this.frameLazy.noteView({
7183
+ deviceId: config.deviceId,
7184
+ materialized: false,
7185
+ workerToRunnerBytes: 0
7186
+ });
7187
+ return materialize;
6456
7188
  },
7189
+ ...useLocalFrameRef ? {
7190
+ frameRegistry: localFrameRegistry,
7191
+ onFrameRef: async (frame, nativeCrop) => {
7192
+ const current = reservation;
7193
+ reservation = null;
7194
+ if (!current || !frame.frameRef) return false;
7195
+ const handle = current.detection ? writeRetainedDetectionFrame(retainedFrames, config.deviceId, frame, this.retention) : void 0;
7196
+ if (handle && nativeCrop) {
7197
+ this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
7198
+ this.bindFrameCutter(handle, nativeCrop);
7199
+ }
7200
+ this.frameLazy.noteView({
7201
+ deviceId: config.deviceId,
7202
+ materialized: false,
7203
+ workerToRunnerBytes: 0
7204
+ });
7205
+ const admitted = current.detection && runner.enqueueReservedDetectionFrame(config.deviceId, frame, handle);
7206
+ if (current.motion) {
7207
+ const motion = await resolveMotionFrameView(localFrameRegistry, frame.frameRef);
7208
+ if (motion.kind === "view") runner.enqueueMotionFrame(config.deviceId, {
7209
+ data: Buffer.from(motion.data),
7210
+ width: motion.width,
7211
+ height: motion.height,
7212
+ format: "gray",
7213
+ timestamp: frame.timestamp,
7214
+ capturedAt: frame.capturedAt
7215
+ }, void 0);
7216
+ }
7217
+ if (admitted) nativeCrop?.markInferred();
7218
+ return admitted;
7219
+ }
7220
+ } : { inlineViewSpec: SPLIT_DETECTION_VIEW },
6457
7221
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "detect"),
6458
7222
  onFirstFrame: (timing) => {
6459
7223
  const phaseActiveAt = this.lastMotionAt.get(config.deviceId);
@@ -6475,7 +7239,12 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6475
7239
  const current = reservation;
6476
7240
  reservation = null;
6477
7241
  if (!current) return;
6478
- const handle = current.detection ? retainedFrames?.write(config.deviceId, frame) ?? void 0 : void 0;
7242
+ this.frameLazy.noteView({
7243
+ deviceId: config.deviceId,
7244
+ materialized: frame.data.byteLength > 0,
7245
+ workerToRunnerBytes: frame.data.byteLength
7246
+ });
7247
+ const handle = current.detection ? writeRetainedDetectionFrame(retainedFrames, config.deviceId, frame, this.retention) : void 0;
6479
7248
  if (handle && nativeCrop) {
6480
7249
  this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
6481
7250
  this.bindFrameCutter(handle, nativeCrop);
@@ -6521,6 +7290,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6521
7290
  fps: config.motionFps,
6522
7291
  format: "gray"
6523
7292
  },
7293
+ inlineViewSpec: MOTION_FRAME_VIEW,
6524
7294
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "motion"),
6525
7295
  onDecodedFrame: (frame) => {
6526
7296
  runner.enqueueMotionFrame(config.deviceId, frame, void 0);
@@ -6694,7 +7464,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6694
7464
  }
6695
7465
  }
6696
7466
  let image;
6697
- if (!this.executorCoLocated()) {
7467
+ const executorCoLocated = this.executorCoLocated();
7468
+ if (!executorCoLocated) {
6698
7469
  if (frame.format === "rgb") try {
6699
7470
  const encoded = await sharp(frame.data, { raw: {
6700
7471
  width: frame.width,
@@ -6706,10 +7477,25 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
6706
7477
  log.debug("runInference: frame JPEG encode failed — falling back to raw pixels", { meta: { error: errMsg(err) } });
6707
7478
  }
6708
7479
  }
7480
+ if (image !== void 0) this.frameLazy.notePoolBytes({
7481
+ deviceId,
7482
+ raw: 0,
7483
+ jpeg: image.byteLength
7484
+ });
7485
+ else if (executorCoLocated && frame.frameRef) this.frameLazy.notePoolBytes({
7486
+ deviceId,
7487
+ raw: 0,
7488
+ jpeg: 0
7489
+ });
7490
+ else this.frameLazy.notePoolBytes({
7491
+ deviceId,
7492
+ raw: frame.data.byteLength,
7493
+ jpeg: 0
7494
+ });
6709
7495
  try {
6710
7496
  return await api.pipelineExecutor.runPipeline.mutate({
6711
7497
  steps: [...dispatchSteps],
6712
- ...image !== void 0 ? { image } : { frame },
7498
+ ...image !== void 0 ? { image } : selectExecutorFrameSource(executorCoLocated, frame.frameRef, frame),
6713
7499
  deviceId,
6714
7500
  ...camConfig?.deviceKey ? { deviceKey: camConfig.deviceKey } : {},
6715
7501
  plane: "frame"
@@ -7065,31 +7851,53 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
7065
7851
  logger: log,
7066
7852
  admitWorker: this.buildLiveWorkerAdmit(deviceId, "occupancy"),
7067
7853
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:occupancy:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
7068
- startPump: (source) => startSessionDecodePump({
7069
- fork: this.forkDecodeWorkerWithLease,
7070
- logger: log,
7071
- source,
7072
- opts: {
7073
- fps: config.detectionFps,
7074
- format: "rgb"
7075
- },
7076
- acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "occupancy"),
7077
- onEnded: () => cleanup(),
7078
- onDecodedFrame: (frame, nativeCrop) => {
7079
- if (settled) return;
7080
- const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
7081
- if (handle && nativeCrop) {
7082
- this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
7083
- this.bindFrameCutter(handle, nativeCrop);
7084
- }
7085
- if (runner.enqueueOccupancyFrame(deviceId, frame, handle)) nativeCrop?.markInferred();
7086
- count++;
7087
- if (count >= frames) {
7088
- clearTimeout(safetyTimer);
7089
- cleanup();
7854
+ startPump: (source) => {
7855
+ const useLocalFrameRef = this.executorCoLocated() && process.env["CAMSTACK_DISABLE_LOCAL_FRAME_REF"] !== "1";
7856
+ return startSessionDecodePump({
7857
+ fork: this.forkDecodeWorkerWithLease,
7858
+ logger: log,
7859
+ source,
7860
+ opts: {
7861
+ fps: config.detectionFps,
7862
+ format: "rgb"
7863
+ },
7864
+ ...useLocalFrameRef ? {
7865
+ frameRegistry: localFrameRegistry,
7866
+ onFrameRef: (frame, nativeCrop) => {
7867
+ if (settled) return false;
7868
+ const handle = writeRetainedDetectionFrame(retainedFrames, config.deviceId, frame, this.retention);
7869
+ if (handle && nativeCrop) {
7870
+ this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
7871
+ this.bindFrameCutter(handle, nativeCrop);
7872
+ }
7873
+ const admitted = runner.enqueueOccupancyFrame(deviceId, frame, handle);
7874
+ if (admitted) nativeCrop?.markInferred();
7875
+ count++;
7876
+ if (count >= frames) {
7877
+ clearTimeout(safetyTimer);
7878
+ cleanup();
7879
+ }
7880
+ return admitted;
7881
+ }
7882
+ } : { inlineViewSpec: SPLIT_DETECTION_VIEW },
7883
+ acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "occupancy"),
7884
+ onEnded: () => cleanup(),
7885
+ onDecodedFrame: (frame, nativeCrop) => {
7886
+ if (settled) return;
7887
+ const handle = writeRetainedDetectionFrame(retainedFrames, config.deviceId, frame, this.retention);
7888
+ if (handle && nativeCrop) {
7889
+ this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
7890
+ this.bindFrameCutter(handle, nativeCrop);
7891
+ }
7892
+ if (runner.enqueueOccupancyFrame(deviceId, frame, handle)) nativeCrop?.markInferred();
7893
+ count++;
7894
+ if (count >= frames) {
7895
+ clearTimeout(safetyTimer);
7896
+ cleanup();
7897
+ }
7090
7898
  }
7091
- }
7092
- })
7899
+ });
7900
+ }
7093
7901
  });
7094
7902
  if (settled) {
7095
7903
  clearTimeout(safetyTimer);