@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
@@ -2,11 +2,11 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-L-1LMGQs.js");
6
- const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-DwAho7HD.js");
7
- const require_worker_protocol = require("../worker-protocol-GGnzfWu3.js");
5
+ const require_dist = require("../dist-BkO76jAp.js");
6
+ const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-28f4R0JN.js");
7
+ const require_worker_protocol = require("../worker-protocol-owR7WMRP.js");
8
8
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
9
- const require_lazy_sharp = require("../lazy-sharp-DfbyQxET.js");
9
+ const require_lazy_sharp = require("../lazy-sharp-BaPl5mRa.js");
10
10
  const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
11
11
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
12
12
  let sharp = require("sharp");
@@ -303,6 +303,48 @@ var NativeCropRegistry = class {
303
303
  }
304
304
  }
305
305
  };
306
+ //#endregion
307
+ //#region src/session-decode/retained-content-fallback.ts
308
+ /**
309
+ * Honest ≤640 RAM fallback for `getNativeCrop` full-frame misses.
310
+ * Content-sized contain (16:9 → 640×360, 4:3 → 640×480, square → 640×640),
311
+ * never a detection default and never a cross-process ref. Task 3 owns
312
+ * whether this rung stays after telemetry.
313
+ */
314
+ var RETAINED_FALLBACK_VIEW = {
315
+ content: {
316
+ width: 640,
317
+ height: 640
318
+ },
319
+ fit: "contain",
320
+ format: "rgb"
321
+ };
322
+ /** Resolve a content-sized RGB view from the process-local registry on demand. */
323
+ function createRetainedContentFallback(registry, ref) {
324
+ return { async materialize() {
325
+ const lease = registry.acquire(ref);
326
+ if (!lease) return null;
327
+ let reason = "error";
328
+ try {
329
+ const view = await lease.resolve(RETAINED_FALLBACK_VIEW);
330
+ if (view.kind !== "view" || view.format !== "rgb" || view.data.byteLength === 0) {
331
+ reason = "drop";
332
+ return null;
333
+ }
334
+ reason = "success";
335
+ return {
336
+ data: Buffer.from(view.data.buffer, view.data.byteOffset, view.data.byteLength),
337
+ width: view.width,
338
+ height: view.height,
339
+ format: "rgb",
340
+ timestamp: ref.timestamp,
341
+ ...ref.capturedAt !== void 0 ? { capturedAt: ref.capturedAt } : {}
342
+ };
343
+ } finally {
344
+ lease.release(reason);
345
+ }
346
+ } };
347
+ }
306
348
  /** {@link RETAINED_FRAME_BUDGET_MB} in bytes. */
307
349
  var RETAINED_FRAME_BUDGET_BYTES = (() => {
308
350
  const raw = Number(process.env["CAMSTACK_SESSION_RETAIN_MB"]);
@@ -350,12 +392,24 @@ var RETAINED_FRAME_KEY_PREFIX = "ram:";
350
392
  function keyOf(handle) {
351
393
  return `${handle.shmId}#${handle.slot}#${handle.seq}`;
352
394
  }
395
+ /**
396
+ * Node-level RAM store of retained rgb detection frames, keyed by
397
+ * `FrameHandle` identity. Latest-wins with a byte budget.
398
+ */
399
+ /** One retained frame plus the device it belongs to (for the per-device floor). */
400
+ /**
401
+ * Reserved footprint of a lazy contain ≤640 RGB view. Worst-case square
402
+ * 640×640×3 so 4:3 and portrait never exceed the reservation; 16:9 hydrates
403
+ * later settle to 640×360.
404
+ */
405
+ var CONTENT_SIZED_RESERVE_BYTES = 640 * 640 * 3;
353
406
  var RetainedFrameStore = class RetainedFrameStore {
354
407
  nodeId;
355
408
  budgetBytes;
356
409
  perDeviceFloor;
357
410
  timeFloorMs;
358
411
  now;
412
+ onEvict;
359
413
  /** Insertion-ordered (Map) → globally oldest is `keys().next()`. */
360
414
  entries = /* @__PURE__ */ new Map();
361
415
  /**
@@ -374,6 +428,7 @@ var RetainedFrameStore = class RetainedFrameStore {
374
428
  this.perDeviceFloor = options.perDeviceFloor ?? RETAINED_FRAME_PER_DEVICE_FLOOR;
375
429
  this.timeFloorMs = options.timeFloorMs ?? RETAINED_FRAME_TIME_FLOOR_MS;
376
430
  this.now = options.now ?? Date.now;
431
+ this.onEvict = options.onEvict;
377
432
  }
378
433
  /** Number of retained frames (tests / metrics). */
379
434
  get size() {
@@ -390,35 +445,76 @@ var RetainedFrameStore = class RetainedFrameStore {
390
445
  * decode-worker IPC delivers a fresh Buffer per frame, so no defensive copy
391
446
  * is needed.
392
447
  */
393
- write(deviceId, frame) {
448
+ write(deviceId, frame, fallback) {
394
449
  if (frame.format !== "rgb") return null;
395
- const seq = ++this.seq;
396
- const byteLength = frame.data.byteLength;
397
- const handle = {
398
- shmId: `${RETAINED_FRAME_KEY_PREFIX}${this.nodeId}:${deviceId}`,
399
- slot: seq % RetainedFrameStore.SLOT_SPREAD,
400
- seq,
401
- width: frame.width,
402
- height: frame.height,
403
- format: "rgb",
404
- pts: performance.now(),
405
- byteLength,
406
- nodeId: this.nodeId,
407
- slotCount: RetainedFrameStore.SLOT_SPREAD
408
- };
409
- const key = keyOf(handle);
410
- this.entries.set(key, {
450
+ const pixelFree = frame.data.byteLength === 0;
451
+ const handle = this.mintHandle(deviceId, frame);
452
+ const reservedBytes = pixelFree ? CONTENT_SIZED_RESERVE_BYTES : handle.byteLength;
453
+ this.insert(handle, {
411
454
  frame,
412
455
  deviceId,
413
- writtenAt: this.now()
456
+ writtenAt: this.now(),
457
+ reservedBytes,
458
+ ...pixelFree && fallback ? { fallback } : {}
414
459
  });
415
- const deviceKeys = this.perDeviceKeys.get(deviceId);
416
- if (deviceKeys) deviceKeys.push(key);
417
- else this.perDeviceKeys.set(deviceId, [key]);
418
- this.bytes += byteLength;
419
- this.evictToBudget();
420
460
  return handle;
421
461
  }
462
+ /**
463
+ * Identity-only handle for native hold/tile. Does not retain pixels or
464
+ * reserve the content-sized worst-case slot — Task 3 continuous path.
465
+ */
466
+ allocateHandle(deviceId, frame) {
467
+ if (frame.format !== "rgb") return null;
468
+ return this.mintHandle(deviceId, frame);
469
+ }
470
+ /**
471
+ * Remember a detection-only on-demand fallback. Reserves the content-sized
472
+ * worst case until hydrate replaces it with the real JPEG/RGB bytes so the
473
+ * budget can evict unrequested detections. A no-detection frame must never
474
+ * call this.
475
+ */
476
+ rememberOnDemand(deviceId, handle, fallback) {
477
+ const key = keyOf(handle);
478
+ if (this.entries.has(key)) return;
479
+ this.insert(handle, {
480
+ frame: {
481
+ data: Buffer.alloc(0),
482
+ width: handle.width,
483
+ height: handle.height,
484
+ format: "rgb",
485
+ timestamp: 0
486
+ },
487
+ deviceId,
488
+ writtenAt: this.now(),
489
+ reservedBytes: CONTENT_SIZED_RESERVE_BYTES,
490
+ fallback
491
+ });
492
+ }
493
+ has(handle) {
494
+ return this.entries.has(keyOf(handle));
495
+ }
496
+ getDeviceId(handle) {
497
+ return this.entries.get(keyOf(handle))?.deviceId;
498
+ }
499
+ statsByDevice(deviceId) {
500
+ const keys = this.perDeviceKeys.get(deviceId) ?? [];
501
+ let bytes = 0;
502
+ for (const key of keys) bytes += this.entries.get(key)?.reservedBytes ?? 0;
503
+ return {
504
+ entries: keys.length,
505
+ bytes
506
+ };
507
+ }
508
+ deviceStats() {
509
+ return [...this.perDeviceKeys.keys()].map((deviceId) => ({
510
+ deviceId,
511
+ ...this.statsByDevice(deviceId)
512
+ }));
513
+ }
514
+ /** Drop one handle (success/error/drop/detach). Idempotent. */
515
+ drop(handle) {
516
+ this.dropKey(keyOf(handle));
517
+ }
422
518
  /** The retained frame for `handle`, or `null` if never retained / already evicted. */
423
519
  getFrame(handle) {
424
520
  return this.entries.get(keyOf(handle))?.frame ?? null;
@@ -433,11 +529,46 @@ var RetainedFrameStore = class RetainedFrameStore {
433
529
  */
434
530
  crop(handle, bbox, maxWidth) {
435
531
  const entry = this.entries.get(keyOf(handle));
436
- if (!entry || entry.frame.format !== "rgb") return null;
532
+ if (!entry || entry.frame.data.byteLength === 0) return null;
533
+ if (entry.frame.format === "jpeg") {
534
+ if (!(bbox.x <= .001 && bbox.y <= .001 && bbox.w >= .999 && bbox.h >= .999)) return null;
535
+ return {
536
+ bytes: entry.frame.data,
537
+ width: entry.frame.width,
538
+ height: entry.frame.height
539
+ };
540
+ }
541
+ if (entry.frame.format !== "rgb") return null;
437
542
  return cropRgb(entry.frame, bbox, maxWidth);
438
543
  }
544
+ /**
545
+ * Full-frame / ROI crop that hydrates a pixel-free handle on demand.
546
+ * A missing fallback or failed materialize is an explicit miss — never a
547
+ * 0-byte raster.
548
+ */
549
+ async cropAsync(handle, bbox, maxWidth) {
550
+ await this.ensureContent(handle);
551
+ return this.crop(handle, bbox, maxWidth);
552
+ }
553
+ /** Materialize a content-sized RGB view once, if the handle still has a fallback. */
554
+ async ensureContent(handle) {
555
+ const key = keyOf(handle);
556
+ const entry = this.entries.get(key);
557
+ if (!entry) return null;
558
+ if (entry.frame.data.byteLength > 0) return entry.frame;
559
+ if (entry.hydrating) return entry.hydrating;
560
+ if (!entry.fallback) return null;
561
+ entry.hydrating = this.hydrate(key, entry);
562
+ try {
563
+ return await entry.hydrating;
564
+ } finally {
565
+ const current = this.entries.get(key);
566
+ if (current) current.hydrating = void 0;
567
+ }
568
+ }
439
569
  /** Drop every retained frame (node/addon shutdown). */
440
570
  clear() {
571
+ for (const entry of this.entries.values()) entry.fallback?.release?.();
441
572
  this.entries.clear();
442
573
  this.perDeviceKeys.clear();
443
574
  this.bytes = 0;
@@ -462,7 +593,7 @@ var RetainedFrameStore = class RetainedFrameStore {
462
593
  if (!entry) continue;
463
594
  if (this.timeFloorMs > 0 && nowMs - entry.writtenAt < this.timeFloorMs) continue;
464
595
  const deviceKeys = this.perDeviceKeys.get(entry.deviceId);
465
- if (deviceKeys && deviceKeys.length > this.perDeviceFloor) this.dropKey(key);
596
+ if (deviceKeys && deviceKeys.length > this.perDeviceFloor) this.dropKey(key, "evict");
466
597
  }
467
598
  if (this.bytes <= this.budgetBytes) return;
468
599
  for (const key of oldestFirst) {
@@ -470,27 +601,68 @@ var RetainedFrameStore = class RetainedFrameStore {
470
601
  const entry = this.entries.get(key);
471
602
  if (!entry) continue;
472
603
  const deviceKeys = this.perDeviceKeys.get(entry.deviceId);
473
- if (deviceKeys && deviceKeys.length > this.perDeviceFloor) this.dropKey(key);
604
+ if (deviceKeys && deviceKeys.length > this.perDeviceFloor) this.dropKey(key, "evict");
474
605
  }
475
606
  if (this.bytes <= this.budgetBytes) return;
476
607
  for (const key of oldestFirst) {
477
608
  if (this.bytes <= this.budgetBytes || this.entries.size <= 1) break;
478
- this.dropKey(key);
609
+ this.dropKey(key, "evict");
479
610
  }
480
611
  if (this.bytes < 0) this.bytes = 0;
481
612
  }
613
+ async hydrate(key, entry) {
614
+ const view = await entry.fallback?.materialize();
615
+ const current = this.entries.get(key);
616
+ if (!current) return null;
617
+ if (!view || view.data.byteLength === 0) return null;
618
+ if (view.format !== "rgb" && view.format !== "jpeg") return null;
619
+ current.frame = view;
620
+ current.fallback = void 0;
621
+ this.bytes -= current.reservedBytes;
622
+ current.reservedBytes = view.data.byteLength;
623
+ this.bytes += current.reservedBytes;
624
+ this.evictToBudget();
625
+ return view;
626
+ }
627
+ mintHandle(deviceId, frame) {
628
+ const seq = ++this.seq;
629
+ const pixelFree = frame.data.byteLength === 0;
630
+ return {
631
+ shmId: `${RETAINED_FRAME_KEY_PREFIX}${this.nodeId}:${deviceId}`,
632
+ slot: seq % RetainedFrameStore.SLOT_SPREAD,
633
+ seq,
634
+ width: frame.width,
635
+ height: frame.height,
636
+ format: "rgb",
637
+ pts: performance.now(),
638
+ byteLength: pixelFree ? 0 : frame.data.byteLength,
639
+ nodeId: this.nodeId,
640
+ slotCount: RetainedFrameStore.SLOT_SPREAD
641
+ };
642
+ }
643
+ insert(handle, entry) {
644
+ const key = keyOf(handle);
645
+ this.entries.set(key, entry);
646
+ const deviceKeys = this.perDeviceKeys.get(entry.deviceId);
647
+ if (deviceKeys) deviceKeys.push(key);
648
+ else this.perDeviceKeys.set(entry.deviceId, [key]);
649
+ this.bytes += entry.reservedBytes;
650
+ this.evictToBudget();
651
+ }
482
652
  /** Remove one frame by key, keeping the byte tally + per-device index exact. */
483
- dropKey(key) {
653
+ dropKey(key, reason = "drop") {
484
654
  const entry = this.entries.get(key);
485
655
  if (!entry) return;
486
656
  this.entries.delete(key);
487
- this.bytes -= entry.frame.data.byteLength;
657
+ entry.fallback?.release?.();
658
+ this.bytes -= entry.reservedBytes;
488
659
  const deviceKeys = this.perDeviceKeys.get(entry.deviceId);
489
660
  if (deviceKeys) {
490
661
  const idx = deviceKeys.indexOf(key);
491
662
  if (idx >= 0) deviceKeys.splice(idx, 1);
492
663
  if (deviceKeys.length === 0) this.perDeviceKeys.delete(entry.deviceId);
493
664
  }
665
+ if (reason === "evict") this.onEvict?.(entry.deviceId);
494
666
  }
495
667
  };
496
668
  /**
@@ -767,7 +939,8 @@ var SessionDecodeSession = class {
767
939
  this.settleNativeCrop(raw.requestId, {
768
940
  bytes: raw.bytes,
769
941
  width: raw.width,
770
- height: raw.height
942
+ height: raw.height,
943
+ ...raw.source ? { source: raw.source } : {}
771
944
  });
772
945
  return;
773
946
  case "nativeCropMiss":
@@ -1027,26 +1200,6 @@ function createSessionDecodeCoordinator(deps) {
1027
1200
  //#endregion
1028
1201
  //#region src/session-decode/session-decode-pump.ts
1029
1202
  /**
1030
- * Detection working width cap — mirrors the shm decoder's
1031
- * `initScaler` (`nodeav-decoder-session.ts`: `maxW = floor(640 / scale)`,
1032
- * `scale = 1` default). The worker decodes full source resolution (e.g. a 4K
1033
- * `native:main` stream); without this downscale `toBuffer` would ship a full
1034
- * 3840×2160 rgb frame (~24 MB) over IPC per pull, which the detection path
1035
- * cannot keep up with. Aspect ratio is preserved. A source already ≤ this
1036
- * width (e.g. the small `native:sub` motion stream) is passed through
1037
- * unscaled, exactly as the shm scaler's `min(srcW, maxW)` no-ops.
1038
- */
1039
- var WORKING_MAX_WIDTH = 640;
1040
- /** Aspect-preserving cap to {@link WORKING_MAX_WIDTH}; `undefined` when no downscale is needed. */
1041
- function resolveWorkingResize(width, height) {
1042
- if (width <= WORKING_MAX_WIDTH || width <= 0) return void 0;
1043
- const outWidth = WORKING_MAX_WIDTH;
1044
- return {
1045
- width: outWidth,
1046
- height: Math.round(outWidth * height / width)
1047
- };
1048
- }
1049
- /**
1050
1203
  * Build a {@link DecodedFrame} from one pulled {@link VideoFrame}. Downscales to
1051
1204
  * the detection working width (see {@link resolveWorkingResize}) so the ROI
1052
1205
  * scaler ships a small frame instead of the full source resolution.
@@ -1055,17 +1208,18 @@ function resolveWorkingResize(width, height) {
1055
1208
  * time) — this is what lets Step 5 MEASURE session-decode frameAge vs the
1056
1209
  * shm-ring cameras.
1057
1210
  */
1058
- async function toDecodedFrame(frame, format) {
1059
- const resize = resolveWorkingResize(frame.image.width, frame.image.height);
1060
- const bytes = await frame.image.toBuffer({
1061
- format,
1062
- ...resize ? { resize } : {}
1063
- });
1211
+ async function toDecodedFrame(frame, format, viewSpec) {
1212
+ const geometry = viewSpec ? require_event_loop_stall_monitor.resolveFrameViewGeometry(frame.image.width, frame.image.height, viewSpec) : void 0;
1213
+ const bytes = await frame.image.toBuffer(geometry ? {
1214
+ crop: geometry.sourceCrop,
1215
+ resize: geometry.output,
1216
+ format: viewSpec?.format ?? format
1217
+ } : { format });
1064
1218
  return {
1065
1219
  data: Buffer.from(bytes),
1066
- width: resize?.width ?? frame.image.width,
1067
- height: resize?.height ?? frame.image.height,
1068
- format,
1220
+ width: geometry?.output.width ?? frame.image.width,
1221
+ height: geometry?.output.height ?? frame.image.height,
1222
+ format: viewSpec?.format ?? format,
1069
1223
  timestamp: frame.timestamp,
1070
1224
  capturedAt: Date.now()
1071
1225
  };
@@ -1122,9 +1276,9 @@ function startSessionDecodePump(deps) {
1122
1276
  frame.image.close();
1123
1277
  break;
1124
1278
  }
1279
+ let imageOwnershipTransferred = false;
1125
1280
  try {
1126
1281
  if (deps.shouldMaterializeFrame && !deps.shouldMaterializeFrame()) continue;
1127
- const decoded = await toDecodedFrame(frame, format);
1128
1282
  const image = frame.image;
1129
1283
  const cropFn = image.nativeCrop;
1130
1284
  const nativeCrop = cropFn ? Object.assign((bbox, maxWidth) => cropFn(bbox, maxWidth), {
@@ -1132,7 +1286,33 @@ function startSessionDecodePump(deps) {
1132
1286
  release: () => image.releaseNativeLease?.(),
1133
1287
  markInferred: () => image.markInferred?.()
1134
1288
  }) : void 0;
1135
- deps.onDecodedFrame(decoded, nativeCrop);
1289
+ if (deps.frameRegistry && deps.onFrameRef) {
1290
+ const capturedAt = Date.now();
1291
+ const ref = deps.frameRegistry.register(image, {
1292
+ timestamp: frame.timestamp,
1293
+ capturedAt
1294
+ });
1295
+ imageOwnershipTransferred = true;
1296
+ if (!ref) continue;
1297
+ const lazyFrame = {
1298
+ data: Buffer.alloc(0),
1299
+ width: ref.width,
1300
+ height: ref.height,
1301
+ format: ref.format,
1302
+ timestamp: ref.timestamp,
1303
+ capturedAt,
1304
+ frameRef: ref
1305
+ };
1306
+ try {
1307
+ if (!await deps.onFrameRef(lazyFrame, nativeCrop)) deps.frameRegistry.release(ref, "drop");
1308
+ } catch (err) {
1309
+ deps.frameRegistry.release(ref, "error");
1310
+ throw err;
1311
+ }
1312
+ } else {
1313
+ const decoded = await toDecodedFrame(frame, format, deps.inlineViewSpec);
1314
+ deps.onDecodedFrame(decoded, nativeCrop);
1315
+ }
1136
1316
  } catch (err) {
1137
1317
  deps.logger.debug("session-decode pump: skipped a frame (decode/consume error)", {
1138
1318
  tags: { deviceId: deps.source.deviceId },
@@ -1142,7 +1322,7 @@ function startSessionDecodePump(deps) {
1142
1322
  }
1143
1323
  });
1144
1324
  } finally {
1145
- frame.image.close();
1325
+ if (!imageOwnershipTransferred) frame.image.close();
1146
1326
  }
1147
1327
  }
1148
1328
  } catch (err) {
@@ -1533,6 +1713,424 @@ function buildBenchRunPipelineInput(req) {
1533
1713
  };
1534
1714
  }
1535
1715
  //#endregion
1716
+ //#region src/pipeline-runner/frame-lazy-metrics.ts
1717
+ function emptyFrameLazyCounters() {
1718
+ return {
1719
+ framesDecoded: 0,
1720
+ framesAdmitted: 0,
1721
+ framesDroppedPixelFree: 0,
1722
+ viewsMaterialized: 0,
1723
+ viewsSkipped: 0,
1724
+ workerToRunnerBytes: 0,
1725
+ runnerToPoolRawBytes: 0,
1726
+ runnerToPoolJpegBytes: 0,
1727
+ onDemandFullFrameRequests: 0,
1728
+ onDemandCropRequests: 0,
1729
+ nativeHits: 0,
1730
+ nativeMisses: 0,
1731
+ tileHits: 0,
1732
+ tileMisses: 0,
1733
+ fallbackHits: 0,
1734
+ fallbackMisses: 0,
1735
+ retainedWritesAvoided: 0,
1736
+ residentRefs: 0,
1737
+ residentBytes: 0,
1738
+ releases: 0,
1739
+ evictions: 0,
1740
+ staleMisses: 0
1741
+ };
1742
+ }
1743
+ var FrameLazyTelemetry = class {
1744
+ node = emptyFrameLazyCounters();
1745
+ cameras = /* @__PURE__ */ new Map();
1746
+ noteDecoded(deviceId) {
1747
+ this.node.framesDecoded += 1;
1748
+ this.camera(deviceId).framesDecoded += 1;
1749
+ }
1750
+ noteAdmitted(deviceId) {
1751
+ this.node.framesAdmitted += 1;
1752
+ this.camera(deviceId).framesAdmitted += 1;
1753
+ }
1754
+ noteDroppedPixelFree(deviceId) {
1755
+ this.node.framesDroppedPixelFree += 1;
1756
+ this.camera(deviceId).framesDroppedPixelFree += 1;
1757
+ }
1758
+ noteView(input) {
1759
+ if (input.materialized) {
1760
+ this.node.viewsMaterialized += 1;
1761
+ this.camera(input.deviceId).viewsMaterialized += 1;
1762
+ } else {
1763
+ this.node.viewsSkipped += 1;
1764
+ this.camera(input.deviceId).viewsSkipped += 1;
1765
+ }
1766
+ this.node.workerToRunnerBytes += input.workerToRunnerBytes;
1767
+ this.camera(input.deviceId).workerToRunnerBytes += input.workerToRunnerBytes;
1768
+ }
1769
+ notePoolBytes(input) {
1770
+ this.node.runnerToPoolRawBytes += input.raw;
1771
+ this.node.runnerToPoolJpegBytes += input.jpeg;
1772
+ this.camera(input.deviceId).runnerToPoolRawBytes += input.raw;
1773
+ this.camera(input.deviceId).runnerToPoolJpegBytes += input.jpeg;
1774
+ }
1775
+ noteOnDemandRequest(input) {
1776
+ if (input.kind === "full-frame") {
1777
+ this.node.onDemandFullFrameRequests += 1;
1778
+ this.camera(input.deviceId).onDemandFullFrameRequests += 1;
1779
+ } else {
1780
+ this.node.onDemandCropRequests += 1;
1781
+ this.camera(input.deviceId).onDemandCropRequests += 1;
1782
+ }
1783
+ }
1784
+ noteHit(kind, deviceId) {
1785
+ this.addHit(this.node, kind, 1);
1786
+ this.addHit(this.camera(deviceId), kind, 1);
1787
+ }
1788
+ noteMiss(kind, deviceId) {
1789
+ this.addMiss(this.node, kind, 1);
1790
+ this.addMiss(this.camera(deviceId), kind, 1);
1791
+ }
1792
+ noteAvoidedWrite(deviceId) {
1793
+ this.node.retainedWritesAvoided += 1;
1794
+ this.camera(deviceId).retainedWritesAvoided += 1;
1795
+ }
1796
+ noteRelease(deviceId) {
1797
+ this.node.releases += 1;
1798
+ if (deviceId !== void 0) this.camera(deviceId).releases += 1;
1799
+ }
1800
+ noteEviction(deviceId) {
1801
+ this.node.evictions += 1;
1802
+ if (deviceId !== void 0) this.camera(deviceId).evictions += 1;
1803
+ }
1804
+ noteStale(deviceId) {
1805
+ this.node.staleMisses += 1;
1806
+ if (deviceId !== void 0) this.camera(deviceId).staleMisses += 1;
1807
+ }
1808
+ /**
1809
+ * Node occupancy. `residentRefs` is entry count (pending + store), not
1810
+ * outstanding registry leases.
1811
+ */
1812
+ setResident(input) {
1813
+ this.node.residentRefs = input.refs;
1814
+ this.node.residentBytes = input.bytes;
1815
+ }
1816
+ setCameraResident(deviceId, input) {
1817
+ const counters = this.camera(deviceId);
1818
+ counters.residentRefs = input.refs;
1819
+ counters.residentBytes = input.bytes;
1820
+ }
1821
+ forgetCamera(deviceId) {
1822
+ this.cameras.delete(deviceId);
1823
+ }
1824
+ /**
1825
+ * Zero occupancy for already-tracked cameras absent from `present`.
1826
+ * Does not create or delete entries — snapshot still does not prune,
1827
+ * and detach still goes through {@link forgetCamera}.
1828
+ */
1829
+ zeroAbsentResidents(present) {
1830
+ for (const [deviceId, counters] of this.cameras) {
1831
+ if (present.has(deviceId)) continue;
1832
+ counters.residentRefs = 0;
1833
+ counters.residentBytes = 0;
1834
+ }
1835
+ }
1836
+ snapshot(deviceIds) {
1837
+ return {
1838
+ node: { ...this.node },
1839
+ cameras: deviceIds.map((deviceId) => ({
1840
+ deviceId,
1841
+ ...this.camera(deviceId)
1842
+ }))
1843
+ };
1844
+ }
1845
+ trackedCameraCount() {
1846
+ return this.cameras.size;
1847
+ }
1848
+ camera(deviceId) {
1849
+ const existing = this.cameras.get(deviceId);
1850
+ if (existing) return existing;
1851
+ const created = emptyFrameLazyCounters();
1852
+ this.cameras.set(deviceId, created);
1853
+ return created;
1854
+ }
1855
+ addHit(counters, kind, n) {
1856
+ if (kind === "native") counters.nativeHits += n;
1857
+ else if (kind === "tile") counters.tileHits += n;
1858
+ else counters.fallbackHits += n;
1859
+ }
1860
+ addMiss(counters, kind, n) {
1861
+ if (kind === "native") counters.nativeMisses += n;
1862
+ else if (kind === "tile") counters.tileMisses += n;
1863
+ else counters.fallbackMisses += n;
1864
+ }
1865
+ };
1866
+ //#endregion
1867
+ //#region src/pipeline-runner/frame-ref-transport.ts
1868
+ /** A local ref never leaves the grouped process; inline remains rollback/split compatibility. */
1869
+ function selectExecutorFrameSource(coLocated, frameRef, inlineFrame) {
1870
+ return coLocated && frameRef ? { frameRef } : { frame: inlineFrame };
1871
+ }
1872
+ //#endregion
1873
+ //#region src/session-decode/on-demand-jpeg-fallback.ts
1874
+ /** Single content-sized JPEG requested after a detection, never a continuous raw. */
1875
+ var ON_DEMAND_JPEG_VIEW = {
1876
+ content: {
1877
+ width: 640,
1878
+ height: 640
1879
+ },
1880
+ fit: "contain",
1881
+ format: "jpeg"
1882
+ };
1883
+ function createOnDemandJpegFallback(registry, ref) {
1884
+ const lease = registry.acquire(ref);
1885
+ let cached;
1886
+ let released = false;
1887
+ const releaseLease = (reason) => {
1888
+ if (released) return;
1889
+ released = true;
1890
+ lease?.release(reason);
1891
+ };
1892
+ return {
1893
+ async materialize() {
1894
+ if (cached !== void 0) return cached;
1895
+ if (!lease) {
1896
+ cached = null;
1897
+ return null;
1898
+ }
1899
+ try {
1900
+ const view = await lease.resolve(ON_DEMAND_JPEG_VIEW);
1901
+ if (view.kind !== "view" || view.format !== "jpeg" || view.data.byteLength === 0) {
1902
+ cached = null;
1903
+ releaseLease("drop");
1904
+ return null;
1905
+ }
1906
+ cached = {
1907
+ data: Buffer.from(view.data.buffer, view.data.byteOffset, view.data.byteLength),
1908
+ width: view.width,
1909
+ height: view.height,
1910
+ format: "jpeg",
1911
+ timestamp: ref.timestamp,
1912
+ ...ref.capturedAt !== void 0 ? { capturedAt: ref.capturedAt } : {}
1913
+ };
1914
+ releaseLease("success");
1915
+ return cached;
1916
+ } catch {
1917
+ cached = null;
1918
+ releaseLease("error");
1919
+ return null;
1920
+ }
1921
+ },
1922
+ release() {
1923
+ releaseLease("eviction");
1924
+ }
1925
+ };
1926
+ }
1927
+ //#endregion
1928
+ //#region src/pipeline-runner/on-demand-retention.ts
1929
+ var CAMSTACK_CONTINUOUS_RETAIN = "CAMSTACK_CONTINUOUS_RETAIN";
1930
+ function isContinuousRetainEnabled(env = process.env) {
1931
+ return env[CAMSTACK_CONTINUOUS_RETAIN] === "1";
1932
+ }
1933
+ function shouldRetainDetectionFrame(input) {
1934
+ if (!input.admitted) return false;
1935
+ if (input.continuousRetain) return true;
1936
+ return input.hasDetection;
1937
+ }
1938
+ function encodeCrossNodeCrop(input) {
1939
+ if (!input.jpeg) return null;
1940
+ return { jpeg: input.jpeg };
1941
+ }
1942
+ function handleKey(handle) {
1943
+ return `${handle.shmId}#${handle.slot}#${handle.seq}`;
1944
+ }
1945
+ /**
1946
+ * Pending occupancy exists only admission → inference result. A detection
1947
+ * transfers to {@link RetainedFrameStore} (authoritative late-media owner).
1948
+ * No-detection leaves zero state. Store eviction owns fallback cleanup.
1949
+ */
1950
+ var OnDemandRetentionSession = class {
1951
+ store;
1952
+ registry;
1953
+ telemetry;
1954
+ continuousRetain;
1955
+ pending = /* @__PURE__ */ new Map();
1956
+ constructor(options) {
1957
+ this.store = options.store;
1958
+ this.registry = options.registry;
1959
+ this.telemetry = options.telemetry;
1960
+ this.continuousRetain = options.continuousRetain;
1961
+ }
1962
+ get pendingSize() {
1963
+ return this.pending.size;
1964
+ }
1965
+ hasPending(handle) {
1966
+ return this.pending.has(handleKey(handle));
1967
+ }
1968
+ admitFrame(deviceId, frame) {
1969
+ if (this.continuousRetain) {
1970
+ const fallback = frame.frameRef ? createRetainedContentFallback(this.registry, frame.frameRef) : void 0;
1971
+ const handle = this.store.write(deviceId, frame, fallback) ?? void 0;
1972
+ if (handle) this.pending.set(handleKey(handle), {
1973
+ deviceId,
1974
+ ref: frame.frameRef
1975
+ });
1976
+ this.refreshResident();
1977
+ return handle;
1978
+ }
1979
+ const handle = this.store.allocateHandle(deviceId, frame) ?? void 0;
1980
+ if (!handle) return void 0;
1981
+ this.pending.set(handleKey(handle), {
1982
+ deviceId,
1983
+ ref: frame.frameRef
1984
+ });
1985
+ this.telemetry.noteAvoidedWrite(deviceId);
1986
+ this.refreshResident();
1987
+ return handle;
1988
+ }
1989
+ noteFrame(input) {
1990
+ if (input.decoded) this.telemetry.noteDecoded(input.deviceId);
1991
+ if (input.admitted) this.telemetry.noteAdmitted(input.deviceId);
1992
+ if (input.droppedPixelFree) this.telemetry.noteDroppedPixelFree(input.deviceId);
1993
+ }
1994
+ noteInferenceResult(deviceId, handle, result) {
1995
+ if (!handle) return;
1996
+ const key = handleKey(handle);
1997
+ const pending = this.pending.get(key);
1998
+ this.forgetPending(key);
1999
+ if (!shouldRetainDetectionFrame({
2000
+ admitted: true,
2001
+ hasDetection: (result.detections?.length ?? 0) > 0,
2002
+ continuousRetain: this.continuousRetain
2003
+ })) {
2004
+ this.refreshResident();
2005
+ return;
2006
+ }
2007
+ if (this.continuousRetain) {
2008
+ this.refreshResident();
2009
+ return;
2010
+ }
2011
+ if (!pending?.ref) {
2012
+ this.refreshResident();
2013
+ return;
2014
+ }
2015
+ const fallback = createOnDemandJpegFallback(this.registry, pending.ref);
2016
+ this.store.rememberOnDemand(deviceId, handle, fallback);
2017
+ this.refreshResident();
2018
+ }
2019
+ async requestMedia(handle, request) {
2020
+ const deviceId = this.store.getDeviceId(handle) ?? this.pending.get(handleKey(handle))?.deviceId ?? 0;
2021
+ const kind = request.kind === "full-frame" ? "full-frame" : "crop";
2022
+ this.telemetry.noteOnDemandRequest({
2023
+ deviceId,
2024
+ kind
2025
+ });
2026
+ if (request.nativeHit) {
2027
+ this.telemetry.noteHit("native", deviceId);
2028
+ return {
2029
+ tier: "native",
2030
+ bytes: request.nativeHit.bytes,
2031
+ width: request.nativeHit.width,
2032
+ height: request.nativeHit.height
2033
+ };
2034
+ }
2035
+ this.telemetry.noteMiss("native", deviceId);
2036
+ if (request.tileHit) {
2037
+ this.telemetry.noteHit("tile", deviceId);
2038
+ return {
2039
+ tier: "tile",
2040
+ bytes: request.tileHit.bytes,
2041
+ jpeg: request.tileHit.jpeg ? request.tileHit.bytes : void 0,
2042
+ width: request.tileHit.width,
2043
+ height: request.tileHit.height
2044
+ };
2045
+ }
2046
+ this.telemetry.noteMiss("tile", deviceId);
2047
+ if (request.recordedStillHit) return {
2048
+ tier: "recorded-still",
2049
+ jpeg: request.recordedStillHit.jpeg,
2050
+ width: request.recordedStillHit.width,
2051
+ height: request.recordedStillHit.height
2052
+ };
2053
+ if (!this.continuousRetain && !this.store.has(handle)) {
2054
+ this.telemetry.noteMiss("fallback", deviceId);
2055
+ return { tier: "miss" };
2056
+ }
2057
+ const view = await this.store.ensureContent(handle);
2058
+ if (view && view.data.byteLength > 0) {
2059
+ this.telemetry.noteHit("fallback", deviceId);
2060
+ this.refreshResident();
2061
+ if (view.format === "jpeg") return {
2062
+ tier: "fallback-jpeg",
2063
+ jpeg: view.data,
2064
+ width: view.width,
2065
+ height: view.height
2066
+ };
2067
+ return {
2068
+ tier: "fallback-jpeg",
2069
+ bytes: view.data,
2070
+ width: view.width,
2071
+ height: view.height
2072
+ };
2073
+ }
2074
+ this.telemetry.noteMiss("fallback", deviceId);
2075
+ this.telemetry.noteStale(deviceId);
2076
+ return { tier: "miss" };
2077
+ }
2078
+ /**
2079
+ * Pending cleanup. Success never invalidates a store entry already
2080
+ * transferred for late pull. Drop/error/detach drop the store only when the
2081
+ * handle was still pending (not yet transferred). Eviction owns transferred
2082
+ * fallback cleanup.
2083
+ */
2084
+ release(handle, reason) {
2085
+ const key = handleKey(handle);
2086
+ const pending = this.pending.get(key);
2087
+ this.forgetPending(key);
2088
+ if (reason !== "success" && pending) this.store.drop(handle);
2089
+ this.telemetry.noteRelease(pending?.deviceId ?? this.store.getDeviceId(handle));
2090
+ this.refreshResident();
2091
+ }
2092
+ forgetDevice(deviceId) {
2093
+ for (const [key, pending] of [...this.pending]) {
2094
+ if (pending.deviceId !== deviceId) continue;
2095
+ this.pending.delete(key);
2096
+ }
2097
+ this.telemetry.forgetCamera(deviceId);
2098
+ this.refreshResident();
2099
+ }
2100
+ syncResident() {
2101
+ this.refreshResident();
2102
+ }
2103
+ forgetPending(key) {
2104
+ this.pending.delete(key);
2105
+ }
2106
+ refreshResident() {
2107
+ const byDevice = /* @__PURE__ */ new Map();
2108
+ for (const pending of this.pending.values()) {
2109
+ const cur = byDevice.get(pending.deviceId) ?? {
2110
+ refs: 0,
2111
+ bytes: 0
2112
+ };
2113
+ cur.refs += 1;
2114
+ byDevice.set(pending.deviceId, cur);
2115
+ }
2116
+ for (const stats of this.store.deviceStats()) {
2117
+ const cur = byDevice.get(stats.deviceId) ?? {
2118
+ refs: 0,
2119
+ bytes: 0
2120
+ };
2121
+ cur.refs += stats.entries;
2122
+ cur.bytes += stats.bytes;
2123
+ byDevice.set(stats.deviceId, cur);
2124
+ }
2125
+ this.telemetry.setResident({
2126
+ refs: this.pending.size + this.store.size,
2127
+ bytes: this.registry.estimatedBytes + this.store.retainedBytes
2128
+ });
2129
+ this.telemetry.zeroAbsentResidents(new Set(byDevice.keys()));
2130
+ for (const [deviceId, stats] of byDevice) this.telemetry.setCameraResident(deviceId, stats);
2131
+ }
2132
+ };
2133
+ //#endregion
1536
2134
  //#region src/pipeline-runner/capacity/capacity-runner.ts
1537
2135
  /**
1538
2136
  * Run ONE ramp step: `cameras` independent open-loop pacers for
@@ -2442,6 +3040,35 @@ function summarizeRunnerHardware(hardware, cpuCores = node_os.cpus().length) {
2442
3040
  };
2443
3041
  }
2444
3042
  //#endregion
3043
+ //#region src/pipeline-runner/motion-frame-view.ts
3044
+ var MOTION_FRAME_VIEW = {
3045
+ content: {
3046
+ width: 320,
3047
+ height: 320
3048
+ },
3049
+ fit: "contain",
3050
+ format: "gray"
3051
+ };
3052
+ /**
3053
+ * Materialize the motion gray view under a short-lived registry lease.
3054
+ * Release is idempotent and always runs, including miss/error.
3055
+ */
3056
+ async function resolveMotionFrameView(registry, ref) {
3057
+ const lease = registry.acquire(ref);
3058
+ if (!lease) return {
3059
+ kind: "miss",
3060
+ reason: ref.registryId === registry.registryId ? "stale-ref" : "foreign-registry"
3061
+ };
3062
+ let reason = "error";
3063
+ try {
3064
+ const view = await lease.resolve(MOTION_FRAME_VIEW);
3065
+ reason = view.kind === "view" ? "success" : "drop";
3066
+ return view;
3067
+ } finally {
3068
+ lease.release(reason);
3069
+ }
3070
+ }
3071
+ //#endregion
2445
3072
  //#region src/pipeline-runner/motion-settle-governor.ts
2446
3073
  /**
2447
3074
  * MotionSettleGovernor — decides WHEN the root-plane `package-detection` step
@@ -2704,6 +3331,13 @@ function resolveNativeCropSource(isFullFrame, nativeHit) {
2704
3331
  if (nativeHit) return "native";
2705
3332
  return isFullFrame ? "ram-fullframe" : "miss";
2706
3333
  }
3334
+ /**
3335
+ * Runtime hit kind for telemetry. A worker tile is native-resolution quality
3336
+ * (public wire `tier` stays `native`) but must not increment `nativeHits`.
3337
+ */
3338
+ function resolveNativeCropHitKind(source) {
3339
+ return source === "tile" ? "tile" : "native";
3340
+ }
2707
3341
  //#endregion
2708
3342
  //#region src/pipeline-runner/native-lease-settings-source.ts
2709
3343
  /**
@@ -2886,18 +3520,23 @@ var FrameQueue = class {
2886
3520
  latest = null;
2887
3521
  _droppedFrames = 0;
2888
3522
  clone;
3523
+ dispose;
2889
3524
  /**
2890
3525
  * `clone` runs on every {@link enqueue} to detach a queue-owned copy from
2891
3526
  * the (possibly borrowed) source — required by the D9 Task 7b ownership
2892
3527
  * contract above. Motion call sites pass {@link ownFrame}; the detection
2893
3528
  * path passes a clone that copies its inner frame's pixel buffer.
2894
3529
  */
2895
- constructor(maxSize, clone) {
3530
+ constructor(maxSize, clone, dispose) {
2896
3531
  this.maxSize = maxSize;
2897
3532
  this.clone = clone;
3533
+ this.dispose = dispose;
2898
3534
  }
2899
3535
  enqueue(item) {
2900
- if (this.latest !== null) this._droppedFrames++;
3536
+ if (this.latest !== null) {
3537
+ this._droppedFrames++;
3538
+ this.dispose?.(this.latest);
3539
+ }
2901
3540
  this.latest = this.clone(item);
2902
3541
  }
2903
3542
  dequeue() {
@@ -2912,6 +3551,7 @@ var FrameQueue = class {
2912
3551
  return this._droppedFrames;
2913
3552
  }
2914
3553
  clear() {
3554
+ if (this.latest !== null) this.dispose?.(this.latest);
2915
3555
  this.latest = null;
2916
3556
  }
2917
3557
  };
@@ -3314,7 +3954,8 @@ function toFrameInput$1(frame) {
3314
3954
  width: frame.width,
3315
3955
  height: frame.height,
3316
3956
  format: frame.format,
3317
- timestamp: frame.timestamp
3957
+ timestamp: frame.timestamp,
3958
+ ...frame.frameRef ? { frameRef: frame.frameRef } : {}
3318
3959
  };
3319
3960
  }
3320
3961
  /**
@@ -3413,7 +4054,10 @@ var PipelineRunner = class {
3413
4054
  }
3414
4055
  registerCamera(deviceId, registration) {
3415
4056
  const motionQueue = new FrameQueue(this.config.maxQueueDepth, ownMotionEntry);
3416
- const detectionQueue = new FrameQueue(this.config.maxQueueDepth, ownDetectionEntry);
4057
+ const detectionQueue = new FrameQueue(this.config.maxQueueDepth, ownDetectionEntry, (entry) => {
4058
+ if (entry.handle) this.config.releaseRetention?.(entry.handle, "drop");
4059
+ if (entry.frame.frameRef) this.config.releaseFrameRef?.(entry.frame.frameRef, "drop");
4060
+ });
3417
4061
  const initialPhase = registration.detectionMode === "disabled" ? "idle" : registration.detectionMode === "always-on" ? "active" : "watching";
3418
4062
  const state = {
3419
4063
  registration,
@@ -3811,9 +4455,13 @@ var PipelineRunner = class {
3811
4455
  };
3812
4456
  const semAcquiredAt = Date.now();
3813
4457
  const executorHandle = state.detectionQueue.size <= HANDLE_PASSTHROUGH_MAX_QUEUE_DEPTH && this.semaphore.available > 0 ? handle : void 0;
4458
+ let frameReleaseReason = "error";
3814
4459
  try {
3815
4460
  const result = await this.awaitInferenceBounded(deviceId, frameInput, executorHandle, release);
3816
- if (result === TIMED_OUT) return;
4461
+ if (result === TIMED_OUT) {
4462
+ frameReleaseReason = "drop";
4463
+ return;
4464
+ }
3817
4465
  const inferDoneAt = Date.now();
3818
4466
  const inferenceMs = inferDoneAt - semAcquiredAt;
3819
4467
  state.inferenceTimes.push(inferenceMs);
@@ -3822,6 +4470,7 @@ var PipelineRunner = class {
3822
4470
  if (result) {
3823
4471
  if (result.shed === true) state.shedFrames++;
3824
4472
  await this.notifyCallbacks(deviceId, frame, result, streamType, handle);
4473
+ frameReleaseReason = "success";
3825
4474
  const emittedAt = Date.now();
3826
4475
  const capturedAt = frame.capturedAt;
3827
4476
  this.timingSampler.addSample(deviceId, {
@@ -3833,9 +4482,16 @@ var PipelineRunner = class {
3833
4482
  endToEnd: emittedAt - enqueuedAt,
3834
4483
  detections: result.detections?.length ?? 0
3835
4484
  });
3836
- }
4485
+ } else frameReleaseReason = "drop";
4486
+ } catch {
4487
+ frameReleaseReason = "error";
3837
4488
  } finally {
3838
4489
  release();
4490
+ try {
4491
+ if (frameReleaseReason === "success" && handle && isContinuousRetainEnabled()) await this.config.hydrateRetainedFrame?.(handle);
4492
+ } catch {}
4493
+ if (handle) this.config.releaseRetention?.(handle, frameReleaseReason);
4494
+ if (frame.frameRef) this.config.releaseFrameRef?.(frame.frameRef, frameReleaseReason);
3839
4495
  }
3840
4496
  }
3841
4497
  /**
@@ -4185,6 +4841,20 @@ var DEFAULT_CONFIG = {
4185
4841
  * than raw RGB — the hop transits the hub main process (UDS child↔child
4186
4842
  * routing), which OOM'd under raw frames. */
4187
4843
  var DETECTION_HOP_JPEG_QUALITY = 82;
4844
+ var SPLIT_DETECTION_VIEW = {
4845
+ content: {
4846
+ width: 640,
4847
+ height: 640
4848
+ },
4849
+ fit: "contain",
4850
+ format: "rgb"
4851
+ };
4852
+ function writeRetainedDetectionFrame(store, deviceId, frame, retention) {
4853
+ if (retention) return retention.admitFrame(deviceId, frame);
4854
+ if (!store) return void 0;
4855
+ if (!isContinuousRetainEnabled()) return store.allocateHandle(deviceId, frame) ?? void 0;
4856
+ return store.write(deviceId, frame, frame.frameRef ? createRetainedContentFallback(require_event_loop_stall_monitor.localFrameRegistry, frame.frameRef) : void 0) ?? void 0;
4857
+ }
4188
4858
  var DETECTION_KEEPWARM_MS = 15e3;
4189
4859
  /**
4190
4860
  * Occupancy-burst safety timeout (both the dial path and the analyzer-tap
@@ -4505,6 +5175,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
4505
5175
  * Initialised in {@link onInitialize} once `nodeId` is known.
4506
5176
  */
4507
5177
  retainedFrames = null;
5178
+ frameLazy = new FrameLazyTelemetry();
5179
+ retention = null;
4508
5180
  attached = /* @__PURE__ */ new Map();
4509
5181
  /**
4510
5182
  * Detection decode workers parked across a detach, keyed by deviceId — see
@@ -4683,7 +5355,15 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
4683
5355
  this.nodeId = raw.includes("/") ? raw.split("/")[0] : raw;
4684
5356
  this.retainedFrames = new RetainedFrameStore({
4685
5357
  nodeId: this.nodeId,
4686
- logger: this.ctx.logger.child("retained-frames")
5358
+ logger: this.ctx.logger.child("retained-frames"),
5359
+ onEvict: (deviceId) => this.frameLazy.noteEviction(deviceId)
5360
+ });
5361
+ this.retention = new OnDemandRetentionSession({
5362
+ store: this.retainedFrames,
5363
+ registry: require_event_loop_stall_monitor.localFrameRegistry,
5364
+ telemetry: this.frameLazy,
5365
+ nodeId: this.nodeId,
5366
+ continuousRetain: isContinuousRetainEnabled()
4687
5367
  });
4688
5368
  this.runner = new PipelineRunner({
4689
5369
  maxQueueDepth: this.config.maxQueueDepth,
@@ -4692,6 +5372,9 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
4692
5372
  minThrottledFps: this.config.minThrottledFps,
4693
5373
  processFrame: (deviceId, frame, handle) => this.runInference(deviceId, frame, handle),
4694
5374
  analyzeMotion: (deviceId, frame, handle) => this.runMotionAnalysis(deviceId, frame, handle),
5375
+ releaseFrameRef: (ref, reason) => require_event_loop_stall_monitor.localFrameRegistry.release(ref, reason),
5376
+ releaseRetention: (handle, reason) => this.retention?.release(handle, reason),
5377
+ hydrateRetainedFrame: (handle) => this.retainedFrames?.ensureContent(handle) ?? Promise.resolve(null),
4695
5378
  onPhaseChanged: (deviceId, phase, meta) => this.handlePhaseChanged(deviceId, phase, meta),
4696
5379
  logger: this.ctx.logger,
4697
5380
  onOccupancyRecheck: (deviceId, frames) => {
@@ -4708,6 +5391,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
4708
5391
  this.handleDetectionStreamChange(deviceId, action);
4709
5392
  });
4710
5393
  this.runner.onResult(async (deviceId, frame, result, _streamType, handle) => {
5394
+ this.retention?.noteInferenceResult(deviceId, handle, result);
4711
5395
  this.cutSubjectTiles(deviceId, result, handle);
4712
5396
  this.emitInferenceResult(deviceId, frame, result, handle);
4713
5397
  const detectionCount = result.detections?.length ?? 0;
@@ -4803,6 +5487,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
4803
5487
  this.nativeCropRegistry.clear();
4804
5488
  this.retainedFrames?.clear();
4805
5489
  this.retainedFrames = null;
5490
+ this.retention = null;
4806
5491
  }
4807
5492
  async cacheBenchFrame(input) {
4808
5493
  const { data, info } = await (await require_lazy_sharp.getSharp())(Buffer.from(input.imageBase64, "base64")).raw().toBuffer({ resolveWithObject: true });
@@ -5555,11 +6240,22 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5555
6240
  * resolve legitimately want the lower-RES honest frame).
5556
6241
  */
5557
6242
  async getNativeCrop(input) {
6243
+ const deviceId = this.nativeCropRegistry.deviceIdFor(input.handle) ?? 0;
6244
+ const isFullFrame = input.bbox.x <= .001 && input.bbox.y <= .001 && input.bbox.w >= .999 && input.bbox.h >= .999;
6245
+ this.frameLazy.noteOnDemandRequest({
6246
+ deviceId,
6247
+ kind: isFullFrame && input.maxWidth === void 0 ? "full-frame" : "crop"
6248
+ });
5558
6249
  const resolved = await this.resolveNativeCropPixels(input);
5559
- if (resolved.tier === "native" && resolved.pixels !== null) return this.encodeNativeCropResult(resolved.pixels, resolved.tier, input.encodeJpeg);
6250
+ if (resolved.tier === "native" && resolved.pixels !== null) {
6251
+ this.frameLazy.noteHit(resolveNativeCropHitKind(resolved.pixels.source), deviceId);
6252
+ return this.encodeNativeCropResult(resolved.pixels, resolved.tier, input.encodeJpeg);
6253
+ }
6254
+ this.frameLazy.noteMiss("native", deviceId);
5560
6255
  if (resolved.tier !== "ram-fullframe") return null;
5561
- const crop = this.retainedFrames?.crop(input.handle, input.bbox, input.maxWidth);
6256
+ const crop = await this.retainedFrames?.cropAsync(input.handle, input.bbox, input.maxWidth);
5562
6257
  if (!crop) {
6258
+ this.frameLazy.noteMiss("fallback", deviceId);
5563
6259
  this.ctx?.logger.warn("native full-frame miss — the retained store no longer holds this frame", {
5564
6260
  tags: { deviceId: this.nativeCropRegistry.deviceIdFor(input.handle) ?? -1 },
5565
6261
  meta: {
@@ -5573,6 +6269,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5573
6269
  });
5574
6270
  return null;
5575
6271
  }
6272
+ this.frameLazy.noteHit("fallback", deviceId);
5576
6273
  return this.encodeNativeCropResult({
5577
6274
  bytes: crop.bytes,
5578
6275
  width: crop.width,
@@ -5595,6 +6292,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5595
6292
  */
5596
6293
  cutSubjectTiles(deviceId, result, handle) {
5597
6294
  const outcome = this.dispatchSubjectTiles(result, handle);
6295
+ if ((result.detections?.length ?? 0) > 0) if (outcome === "sent") this.frameLazy.noteHit("tile", deviceId);
6296
+ else this.frameLazy.noteMiss("tile", deviceId);
5598
6297
  if (outcome === "sent") return;
5599
6298
  const tally = this.tileCutDrops.get(deviceId) ?? {
5600
6299
  handleless: 0,
@@ -5696,7 +6395,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5696
6395
  if (result) nativeResult = {
5697
6396
  bytes: result.bytes,
5698
6397
  width: result.width,
5699
- height: result.height
6398
+ height: result.height,
6399
+ ...result.source ? { source: result.source } : {}
5700
6400
  };
5701
6401
  else missReason = "worker-lease-gone";
5702
6402
  } else missReason = "no-registry-entry";
@@ -5735,6 +6435,16 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5735
6435
  * (`null`) — the caller retries — never a silent raw-RGB cross-node fallback.
5736
6436
  */
5737
6437
  async encodeNativeCropResult(crop, tier, encodeJpeg) {
6438
+ if (crop.bytes.byteLength >= 2 && crop.bytes[0] === 255 && crop.bytes[1] === 216) {
6439
+ const encoded = encodeCrossNodeCrop({ jpeg: Buffer.from(crop.bytes).toString("base64") });
6440
+ if (!encoded) return null;
6441
+ return {
6442
+ jpeg: encoded.jpeg,
6443
+ width: crop.width,
6444
+ height: crop.height,
6445
+ tier
6446
+ };
6447
+ }
5738
6448
  if (encodeJpeg !== true) return {
5739
6449
  bytes: crop.bytes,
5740
6450
  width: crop.width,
@@ -5742,12 +6452,17 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5742
6452
  tier
5743
6453
  };
5744
6454
  try {
5745
- return {
6455
+ const encoded = encodeCrossNodeCrop({
5746
6456
  jpeg: (await (await require_lazy_sharp.getSharp())(Buffer.from(crop.bytes), { raw: {
5747
6457
  width: crop.width,
5748
6458
  height: crop.height,
5749
6459
  channels: 3
5750
6460
  } }).jpeg({ quality: 90 }).toBuffer()).toString("base64"),
6461
+ bytes: crop.bytes
6462
+ });
6463
+ if (!encoded) return null;
6464
+ return {
6465
+ jpeg: encoded.jpeg,
5751
6466
  width: crop.width,
5752
6467
  height: crop.height,
5753
6468
  tier
@@ -6088,6 +6803,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
6088
6803
  this.attached.delete(deviceId);
6089
6804
  this.lastMotionAt.delete(deviceId);
6090
6805
  this.lastEmittedCameraMetrics.delete(deviceId);
6806
+ this.retention?.forgetDevice(deviceId);
6091
6807
  this.runner?.unregisterCamera(deviceId);
6092
6808
  this.ctx?.logger.info("detachCamera", {
6093
6809
  tags: { deviceId },
@@ -6282,9 +6998,11 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
6282
6998
  avgInferenceTimeMs: 0,
6283
6999
  queueDepth: 0
6284
7000
  };
7001
+ this.retention?.syncResident();
6285
7002
  return {
6286
7003
  nodeId: this.nodeId,
6287
- ...m
7004
+ ...m,
7005
+ frameLazy: this.frameLazy.snapshot(this.getLocalCameras())
6288
7006
  };
6289
7007
  }
6290
7008
  async getCameraMetrics(input) {
@@ -6444,9 +7162,11 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
6444
7162
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:detect:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config), true),
6445
7163
  startPump: (source) => {
6446
7164
  const dialMs = Date.now() - requestedAt;
7165
+ const useLocalFrameRef = this.executorCoLocated() && process.env["CAMSTACK_DISABLE_LOCAL_FRAME_REF"] !== "1";
6447
7166
  log.info("session-decode: detection routed through per-session decode worker (rgb)", { meta: {
6448
7167
  streamId: config.detectionStreamId,
6449
- codec: source.codec
7168
+ codec: source.codec,
7169
+ transport: useLocalFrameRef ? "local-frame-ref" : "inline-compat"
6450
7170
  } });
6451
7171
  let reservation = null;
6452
7172
  return startSessionDecodePump({
@@ -6459,8 +7179,52 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
6459
7179
  },
6460
7180
  shouldMaterializeFrame: () => {
6461
7181
  reservation = this.reserveSharedDetectionFrame(config.deviceId);
6462
- return reservation.detection || reservation.motion;
7182
+ const materialize = reservation.detection || reservation.motion;
7183
+ this.retention?.noteFrame({
7184
+ deviceId: config.deviceId,
7185
+ decoded: true,
7186
+ admitted: reservation.detection,
7187
+ droppedPixelFree: !materialize
7188
+ });
7189
+ if (!materialize) this.frameLazy.noteView({
7190
+ deviceId: config.deviceId,
7191
+ materialized: false,
7192
+ workerToRunnerBytes: 0
7193
+ });
7194
+ return materialize;
6463
7195
  },
7196
+ ...useLocalFrameRef ? {
7197
+ frameRegistry: require_event_loop_stall_monitor.localFrameRegistry,
7198
+ onFrameRef: async (frame, nativeCrop) => {
7199
+ const current = reservation;
7200
+ reservation = null;
7201
+ if (!current || !frame.frameRef) return false;
7202
+ const handle = current.detection ? writeRetainedDetectionFrame(retainedFrames, config.deviceId, frame, this.retention) : void 0;
7203
+ if (handle && nativeCrop) {
7204
+ this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
7205
+ this.bindFrameCutter(handle, nativeCrop);
7206
+ }
7207
+ this.frameLazy.noteView({
7208
+ deviceId: config.deviceId,
7209
+ materialized: false,
7210
+ workerToRunnerBytes: 0
7211
+ });
7212
+ const admitted = current.detection && runner.enqueueReservedDetectionFrame(config.deviceId, frame, handle);
7213
+ if (current.motion) {
7214
+ const motion = await resolveMotionFrameView(require_event_loop_stall_monitor.localFrameRegistry, frame.frameRef);
7215
+ if (motion.kind === "view") runner.enqueueMotionFrame(config.deviceId, {
7216
+ data: Buffer.from(motion.data),
7217
+ width: motion.width,
7218
+ height: motion.height,
7219
+ format: "gray",
7220
+ timestamp: frame.timestamp,
7221
+ capturedAt: frame.capturedAt
7222
+ }, void 0);
7223
+ }
7224
+ if (admitted) nativeCrop?.markInferred();
7225
+ return admitted;
7226
+ }
7227
+ } : { inlineViewSpec: SPLIT_DETECTION_VIEW },
6464
7228
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "detect"),
6465
7229
  onFirstFrame: (timing) => {
6466
7230
  const phaseActiveAt = this.lastMotionAt.get(config.deviceId);
@@ -6482,7 +7246,12 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
6482
7246
  const current = reservation;
6483
7247
  reservation = null;
6484
7248
  if (!current) return;
6485
- const handle = current.detection ? retainedFrames?.write(config.deviceId, frame) ?? void 0 : void 0;
7249
+ this.frameLazy.noteView({
7250
+ deviceId: config.deviceId,
7251
+ materialized: frame.data.byteLength > 0,
7252
+ workerToRunnerBytes: frame.data.byteLength
7253
+ });
7254
+ const handle = current.detection ? writeRetainedDetectionFrame(retainedFrames, config.deviceId, frame, this.retention) : void 0;
6486
7255
  if (handle && nativeCrop) {
6487
7256
  this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
6488
7257
  this.bindFrameCutter(handle, nativeCrop);
@@ -6528,6 +7297,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
6528
7297
  fps: config.motionFps,
6529
7298
  format: "gray"
6530
7299
  },
7300
+ inlineViewSpec: MOTION_FRAME_VIEW,
6531
7301
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "motion"),
6532
7302
  onDecodedFrame: (frame) => {
6533
7303
  runner.enqueueMotionFrame(config.deviceId, frame, void 0);
@@ -6701,7 +7471,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
6701
7471
  }
6702
7472
  }
6703
7473
  let image;
6704
- if (!this.executorCoLocated()) {
7474
+ const executorCoLocated = this.executorCoLocated();
7475
+ if (!executorCoLocated) {
6705
7476
  if (frame.format === "rgb") try {
6706
7477
  const encoded = await (0, sharp.default)(frame.data, { raw: {
6707
7478
  width: frame.width,
@@ -6713,10 +7484,25 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
6713
7484
  log.debug("runInference: frame JPEG encode failed — falling back to raw pixels", { meta: { error: require_dist.errMsg(err) } });
6714
7485
  }
6715
7486
  }
7487
+ if (image !== void 0) this.frameLazy.notePoolBytes({
7488
+ deviceId,
7489
+ raw: 0,
7490
+ jpeg: image.byteLength
7491
+ });
7492
+ else if (executorCoLocated && frame.frameRef) this.frameLazy.notePoolBytes({
7493
+ deviceId,
7494
+ raw: 0,
7495
+ jpeg: 0
7496
+ });
7497
+ else this.frameLazy.notePoolBytes({
7498
+ deviceId,
7499
+ raw: frame.data.byteLength,
7500
+ jpeg: 0
7501
+ });
6716
7502
  try {
6717
7503
  return await api.pipelineExecutor.runPipeline.mutate({
6718
7504
  steps: [...dispatchSteps],
6719
- ...image !== void 0 ? { image } : { frame },
7505
+ ...image !== void 0 ? { image } : selectExecutorFrameSource(executorCoLocated, frame.frameRef, frame),
6720
7506
  deviceId,
6721
7507
  ...camConfig?.deviceKey ? { deviceKey: camConfig.deviceKey } : {},
6722
7508
  plane: "frame"
@@ -7072,31 +7858,53 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
7072
7858
  logger: log,
7073
7859
  admitWorker: this.buildLiveWorkerAdmit(deviceId, "occupancy"),
7074
7860
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:occupancy:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
7075
- startPump: (source) => startSessionDecodePump({
7076
- fork: this.forkDecodeWorkerWithLease,
7077
- logger: log,
7078
- source,
7079
- opts: {
7080
- fps: config.detectionFps,
7081
- format: "rgb"
7082
- },
7083
- acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "occupancy"),
7084
- onEnded: () => cleanup(),
7085
- onDecodedFrame: (frame, nativeCrop) => {
7086
- if (settled) return;
7087
- const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
7088
- if (handle && nativeCrop) {
7089
- this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
7090
- this.bindFrameCutter(handle, nativeCrop);
7091
- }
7092
- if (runner.enqueueOccupancyFrame(deviceId, frame, handle)) nativeCrop?.markInferred();
7093
- count++;
7094
- if (count >= frames) {
7095
- clearTimeout(safetyTimer);
7096
- cleanup();
7861
+ startPump: (source) => {
7862
+ const useLocalFrameRef = this.executorCoLocated() && process.env["CAMSTACK_DISABLE_LOCAL_FRAME_REF"] !== "1";
7863
+ return startSessionDecodePump({
7864
+ fork: this.forkDecodeWorkerWithLease,
7865
+ logger: log,
7866
+ source,
7867
+ opts: {
7868
+ fps: config.detectionFps,
7869
+ format: "rgb"
7870
+ },
7871
+ ...useLocalFrameRef ? {
7872
+ frameRegistry: require_event_loop_stall_monitor.localFrameRegistry,
7873
+ onFrameRef: (frame, nativeCrop) => {
7874
+ if (settled) return false;
7875
+ const handle = writeRetainedDetectionFrame(retainedFrames, config.deviceId, frame, this.retention);
7876
+ if (handle && nativeCrop) {
7877
+ this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
7878
+ this.bindFrameCutter(handle, nativeCrop);
7879
+ }
7880
+ const admitted = runner.enqueueOccupancyFrame(deviceId, frame, handle);
7881
+ if (admitted) nativeCrop?.markInferred();
7882
+ count++;
7883
+ if (count >= frames) {
7884
+ clearTimeout(safetyTimer);
7885
+ cleanup();
7886
+ }
7887
+ return admitted;
7888
+ }
7889
+ } : { inlineViewSpec: SPLIT_DETECTION_VIEW },
7890
+ acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "occupancy"),
7891
+ onEnded: () => cleanup(),
7892
+ onDecodedFrame: (frame, nativeCrop) => {
7893
+ if (settled) return;
7894
+ const handle = writeRetainedDetectionFrame(retainedFrames, config.deviceId, frame, this.retention);
7895
+ if (handle && nativeCrop) {
7896
+ this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
7897
+ this.bindFrameCutter(handle, nativeCrop);
7898
+ }
7899
+ if (runner.enqueueOccupancyFrame(deviceId, frame, handle)) nativeCrop?.markInferred();
7900
+ count++;
7901
+ if (count >= frames) {
7902
+ clearTimeout(safetyTimer);
7903
+ cleanup();
7904
+ }
7097
7905
  }
7098
- }
7099
- })
7906
+ });
7907
+ }
7100
7908
  });
7101
7909
  if (settled) {
7102
7910
  clearTimeout(safetyTimer);