@camstack/addon-pipeline 1.2.64 → 1.2.67

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 (89) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +2 -2
  4. package/dist/detection-pipeline/index.mjs +2 -2
  5. package/dist/{dist-DKrqeIfS.js → dist-B6GWr9am.js} +151 -6
  6. package/dist/{dist-B9_C-QmC.mjs → dist-BEbyhqoG.mjs} +146 -7
  7. package/dist/{event-loop-stall-monitor-CnJ5JGA2.js → event-loop-stall-monitor-BHOEgVnP.js} +1 -1
  8. package/dist/{event-loop-stall-monitor-BucTbe9v.mjs → event-loop-stall-monitor-BNyf5yEt.mjs} +1 -1
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +144 -14
  12. package/dist/pipeline-runner/index.mjs +144 -14
  13. package/dist/recorder/index.js +114 -3
  14. package/dist/recorder/index.mjs +114 -3
  15. package/dist/remote-restream-BYbAsgUf.js +89 -0
  16. package/dist/remote-restream-Ci7RXNGb.mjs +66 -0
  17. package/dist/session-decode/decode-worker-child.js +1 -1
  18. package/dist/session-decode/decode-worker-child.mjs +1 -1
  19. package/dist/stream-broker/_stub.js +2 -2
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DkDAcqBW.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BkKnIpb0.mjs} +2 -2
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-ircnzt0s.mjs +26 -0
  22. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CJAUKSrT.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DU7u9EJt.mjs} +1 -1
  23. package/dist/stream-broker/{hostInit-C-peB-6a.mjs → hostInit-y3zWHWoe.mjs} +2 -2
  24. package/dist/stream-broker/index.js +172 -36
  25. package/dist/stream-broker/index.mjs +172 -36
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/{worker-protocol-CQHY2NeB.mjs → worker-protocol-CBg9bqCs.mjs} +1 -1
  28. package/dist/{worker-protocol-CDkbdSuM.js → worker-protocol-qzaozj7t.js} +1 -1
  29. package/package.json +1 -1
  30. package/dist/remote-restream-BeHi78PZ.mjs +0 -25
  31. package/dist/remote-restream-CO36Sr30.js +0 -36
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-C2tu4c14.mjs +0 -26
  33. package/python/__pycache__/inference_pool.cpython-312.pyc +0 -0
  34. package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
  35. package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
  36. package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
  37. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
  38. package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
  39. package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
  40. package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
  41. package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
  42. package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
  43. package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
  44. package/python/postprocessors/__pycache__/__init__.cpython-314.pyc +0 -0
  45. package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
  46. package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
  47. package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
  48. package/python/postprocessors/__pycache__/arcface.cpython-314.pyc +0 -0
  49. package/python/postprocessors/__pycache__/clip.cpython-312.pyc +0 -0
  50. package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
  51. package/python/postprocessors/__pycache__/clip.cpython-314.pyc +0 -0
  52. package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
  53. package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
  54. package/python/postprocessors/__pycache__/ctc.cpython-314.pyc +0 -0
  55. package/python/postprocessors/__pycache__/rfdetr.cpython-312.pyc +0 -0
  56. package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
  57. package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
  58. package/python/postprocessors/__pycache__/saliency.cpython-314.pyc +0 -0
  59. package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
  60. package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
  61. package/python/postprocessors/__pycache__/scrfd.cpython-314.pyc +0 -0
  62. package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
  63. package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
  64. package/python/postprocessors/__pycache__/softmax.cpython-314.pyc +0 -0
  65. package/python/postprocessors/__pycache__/ssd.cpython-312.pyc +0 -0
  66. package/python/postprocessors/__pycache__/ssd.cpython-313.pyc +0 -0
  67. package/python/postprocessors/__pycache__/ssd.cpython-314.pyc +0 -0
  68. package/python/postprocessors/__pycache__/test_clip.cpython-312-pytest-9.1.1.pyc +0 -0
  69. package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
  70. package/python/postprocessors/__pycache__/test_ctc.cpython-312-pytest-9.1.1.pyc +0 -0
  71. package/python/postprocessors/__pycache__/test_ctc.cpython-312.pyc +0 -0
  72. package/python/postprocessors/__pycache__/test_ctc.cpython-313-pytest-9.1.1.pyc +0 -0
  73. package/python/postprocessors/__pycache__/test_ctc.cpython-313.pyc +0 -0
  74. package/python/postprocessors/__pycache__/test_rfdetr.cpython-312-pytest-9.1.1.pyc +0 -0
  75. package/python/postprocessors/__pycache__/test_saliency.cpython-312-pytest-9.1.1.pyc +0 -0
  76. package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
  77. package/python/postprocessors/__pycache__/test_softmax.cpython-312-pytest-9.1.1.pyc +0 -0
  78. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-312-pytest-9.1.1.pyc +0 -0
  79. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-39.pyc +0 -0
  80. package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
  81. package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
  82. package/python/postprocessors/__pycache__/yamnet.cpython-314.pyc +0 -0
  83. package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
  84. package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
  85. package/python/postprocessors/__pycache__/yolo.cpython-314.pyc +0 -0
  86. package/python/postprocessors/__pycache__/yolo.cpython-39.pyc +0 -0
  87. package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
  88. package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
  89. package/python/postprocessors/__pycache__/yolo_seg.cpython-314.pyc +0 -0
@@ -3,12 +3,12 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-DKrqeIfS.js");
7
- const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-CnJ5JGA2.js");
8
- const require_worker_protocol = require("../worker-protocol-CDkbdSuM.js");
6
+ const require_dist = require("../dist-B6GWr9am.js");
7
+ const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-BHOEgVnP.js");
8
+ const require_worker_protocol = require("../worker-protocol-qzaozj7t.js");
9
9
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
10
10
  const require_lazy_sharp = require("../lazy-sharp-CcYuXtsa.js");
11
- const require_remote_restream = require("../remote-restream-CO36Sr30.js");
11
+ const require_remote_restream = require("../remote-restream-BYbAsgUf.js");
12
12
  let sharp = require("sharp");
13
13
  sharp = require_chunk.__toESM(sharp);
14
14
  let node_child_process = require("node:child_process");
@@ -1086,6 +1086,7 @@ function startSessionDecodePump(deps) {
1086
1086
  releaseSpawnSlot = null;
1087
1087
  release();
1088
1088
  };
1089
+ const pumpStartedAt = Date.now();
1089
1090
  const pump = async () => {
1090
1091
  try {
1091
1092
  if (deps.acquireSpawnSlot) {
@@ -1095,11 +1096,16 @@ function startSessionDecodePump(deps) {
1095
1096
  return;
1096
1097
  }
1097
1098
  }
1099
+ const spawnGrantedAt = Date.now();
1098
1100
  let firstFrameSeen = false;
1099
1101
  for await (const frame of iterator) {
1100
1102
  if (!firstFrameSeen) {
1101
1103
  firstFrameSeen = true;
1102
1104
  releaseSpawnSlotOnce();
1105
+ deps.onFirstFrame?.({
1106
+ spawnWaitMs: spawnGrantedAt - pumpStartedAt,
1107
+ forkToFrameMs: Date.now() - spawnGrantedAt
1108
+ });
1103
1109
  }
1104
1110
  if (stopped) {
1105
1111
  frame.image.close();
@@ -4033,7 +4039,7 @@ function resolveOwnerRestreamHost(frameSource, hubUrl) {
4033
4039
  * thin delegator to this function — its two call sites are unchanged.
4034
4040
  */
4035
4041
  async function acquireSessionDecodeRestreamFrom(api, logger, params) {
4036
- const { deviceId, streamId, tag, hostname, ownerNodeId } = params;
4042
+ const { deviceId, streamId, tag, hostname, ownerNodeId, detectionIntent } = params;
4037
4043
  const pin = ownerNodeId !== void 0 ? require_dist.nodePin(ownerNodeId) : void 0;
4038
4044
  const profile = require_remote_restream.profileForStreamId(await api.streamBroker.listAllProfileSlots.query(void 0, pin), deviceId, streamId);
4039
4045
  if (profile === null) {
@@ -4053,7 +4059,7 @@ async function acquireSessionDecodeRestreamFrom(api, logger, params) {
4053
4059
  }, pin);
4054
4060
  const codec = rtpSource.videoCodec === "H265" ? "h265" : "h264";
4055
4061
  const source = {
4056
- restreamUrl: rtpSource.url,
4062
+ restreamUrl: detectionIntent ? require_remote_restream.withDetectionIntent(rtpSource.url) : rtpSource.url,
4057
4063
  deviceId,
4058
4064
  codec
4059
4065
  };
@@ -4120,6 +4126,12 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
4120
4126
  */
4121
4127
  retainedFrames = null;
4122
4128
  attached = /* @__PURE__ */ new Map();
4129
+ /**
4130
+ * Detection decode workers parked across a detach, keyed by deviceId — see
4131
+ * {@link WarmDetachedDecode}. Bounded by construction: at most one entry per
4132
+ * camera, each self-expiring after {@link DETECTION_KEEPWARM_MS}.
4133
+ */
4134
+ warmDetachedDetection = /* @__PURE__ */ new Map();
4123
4135
  nodeId = "unknown";
4124
4136
  metricsSnapshotTimer = null;
4125
4137
  unsubMotionEvents = null;
@@ -4347,6 +4359,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
4347
4359
  attachment.motionUnsubscribe?.();
4348
4360
  attachment.detectionUnsubscribe?.();
4349
4361
  }
4362
+ for (const deviceId of [...this.warmDetachedDetection.keys()]) this.dropWarmDetectionDecode(deviceId, "shutdown");
4350
4363
  this.attached.clear();
4351
4364
  this.lastMotionFrameAt.clear();
4352
4365
  this.nativeCropRegistry.clear();
@@ -5050,7 +5063,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5050
5063
  return { success: true };
5051
5064
  }
5052
5065
  async detachCamera(input) {
5053
- this.detachInternal(input.deviceId);
5066
+ this.detachInternal(input.deviceId, true);
5054
5067
  return { success: true };
5055
5068
  }
5056
5069
  async reportMotion(input) {
@@ -5445,7 +5458,10 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5445
5458
  */
5446
5459
  handleDetectionDecodeEnded(deviceId) {
5447
5460
  const attachment = this.attached.get(deviceId);
5448
- if (!attachment) return;
5461
+ if (!attachment) {
5462
+ this.dropWarmDetectionDecode(deviceId, "worker-died-while-parked");
5463
+ return;
5464
+ }
5449
5465
  const log = this.ctx.logger.withTags({ deviceId });
5450
5466
  attachment.detectionUnsubscribe?.();
5451
5467
  attachment.detectionUnsubscribe = null;
@@ -5476,7 +5492,18 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5476
5492
  });
5477
5493
  }, DETECTION_REDIAL_DELAY_MS).unref?.();
5478
5494
  }
5479
- detachInternal(deviceId) {
5495
+ /**
5496
+ * Tear a camera down.
5497
+ *
5498
+ * `keepDecodeWarm` routes the DETECTION decode through the same keep-warm
5499
+ * grace the phase gate uses instead of killing it — see
5500
+ * {@link WarmDetachedDecode}. Set only by the {@link detachCamera} cap entry
5501
+ * point (the orchestrator's motion-cooldown teardown); every internal detach
5502
+ * — an attach replacing a stale config, a decode that died twice, shutdown —
5503
+ * is a hard one, because in each of those cases the worker is either wrong or
5504
+ * already gone.
5505
+ */
5506
+ detachInternal(deviceId, keepDecodeWarm = false) {
5480
5507
  const attachment = this.attached.get(deviceId);
5481
5508
  if (!attachment) return;
5482
5509
  this.clearOnboardAnalyzer(deviceId);
@@ -5485,7 +5512,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5485
5512
  attachment.detectionKeepWarmTimer = null;
5486
5513
  }
5487
5514
  attachment.motionUnsubscribe?.();
5488
- attachment.detectionUnsubscribe?.();
5515
+ if (keepDecodeWarm && attachment.detectionUnsubscribe !== null) this.parkWarmDetectionDecode(deviceId, attachment.config, attachment.detectionUnsubscribe);
5516
+ else attachment.detectionUnsubscribe?.();
5489
5517
  this.lastMotionFrameAt.delete(deviceId);
5490
5518
  this.lastDecodeUnexpectedEndAt.delete(deviceId);
5491
5519
  this.packageGovernor.forget(deviceId);
@@ -5495,7 +5523,86 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5495
5523
  this.lastMotionAt.delete(deviceId);
5496
5524
  this.lastEmittedCameraMetrics.delete(deviceId);
5497
5525
  this.runner?.unregisterCamera(deviceId);
5498
- this.ctx?.logger.info("detachCamera", { tags: { deviceId } });
5526
+ this.ctx?.logger.info("detachCamera", {
5527
+ tags: { deviceId },
5528
+ meta: { decodeKeptWarm: this.warmDetachedDetection.has(deviceId) }
5529
+ });
5530
+ }
5531
+ /**
5532
+ * The decode-relevant identity of a camera's detection decode. Two configs
5533
+ * that agree on all of it can share one worker; anything else must re-fork.
5534
+ * Kept in ONE place so a future decode-shaping option cannot be added to the
5535
+ * pump without the adoption check noticing.
5536
+ */
5537
+ detectionDecodeIdentity(config) {
5538
+ return {
5539
+ streamId: config.detectionStreamId,
5540
+ fps: config.detectionFps,
5541
+ hostname: resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]),
5542
+ ownerNodeId: resolveSessionDecodeOwnerNodeId(config)
5543
+ };
5544
+ }
5545
+ /**
5546
+ * Hold a detached camera's detection decode worker for
5547
+ * {@link DETECTION_KEEPWARM_MS} so the next motion event reuses it.
5548
+ * Replacing an existing park releases the older worker first — two parked
5549
+ * workers for one camera would be two live decodes and two broker leases.
5550
+ */
5551
+ parkWarmDetectionDecode(deviceId, config, unsubscribe) {
5552
+ this.dropWarmDetectionDecode(deviceId, "replaced");
5553
+ const timer = setTimeout(() => {
5554
+ this.warmDetachedDetection.delete(deviceId);
5555
+ unsubscribe();
5556
+ this.ctx?.logger.info("session-decode: warm detection decode expired — worker released", {
5557
+ tags: { deviceId },
5558
+ meta: { keepWarmMs: DETECTION_KEEPWARM_MS }
5559
+ });
5560
+ }, DETECTION_KEEPWARM_MS);
5561
+ timer.unref?.();
5562
+ this.warmDetachedDetection.set(deviceId, {
5563
+ unsubscribe,
5564
+ timer,
5565
+ ...this.detectionDecodeIdentity(config)
5566
+ });
5567
+ }
5568
+ /**
5569
+ * Adopt a parked decode worker for a camera that is attaching again, or
5570
+ * `null` when there is nothing to adopt.
5571
+ *
5572
+ * A parked worker whose identity no longer matches is DROPPED here rather
5573
+ * than left to expire: the caller is about to fork a replacement, and letting
5574
+ * both run would double this camera's decode cost for the rest of the window.
5575
+ */
5576
+ adoptWarmDetectionDecode(deviceId, config) {
5577
+ const warm = this.warmDetachedDetection.get(deviceId);
5578
+ if (!warm) return null;
5579
+ const wanted = this.detectionDecodeIdentity(config);
5580
+ if (warm.streamId !== wanted.streamId || warm.fps !== wanted.fps || warm.hostname !== wanted.hostname || warm.ownerNodeId !== wanted.ownerNodeId) {
5581
+ this.dropWarmDetectionDecode(deviceId, "config-changed");
5582
+ return null;
5583
+ }
5584
+ clearTimeout(warm.timer);
5585
+ this.warmDetachedDetection.delete(deviceId);
5586
+ this.ctx?.logger.info("session-decode: reusing the warm detection decode worker (no re-fork)", {
5587
+ tags: { deviceId },
5588
+ meta: {
5589
+ streamId: warm.streamId,
5590
+ fps: warm.fps
5591
+ }
5592
+ });
5593
+ return warm.unsubscribe;
5594
+ }
5595
+ /** Release a parked worker now. `reason` names why in the log. */
5596
+ dropWarmDetectionDecode(deviceId, reason) {
5597
+ const warm = this.warmDetachedDetection.get(deviceId);
5598
+ if (!warm) return;
5599
+ clearTimeout(warm.timer);
5600
+ this.warmDetachedDetection.delete(deviceId);
5601
+ warm.unsubscribe();
5602
+ this.ctx?.logger.info("session-decode: warm detection decode released", {
5603
+ tags: { deviceId },
5604
+ meta: { reason }
5605
+ });
5499
5606
  }
5500
5607
  /**
5501
5608
  * Synchronously cancel the teardown timer and call the unsubscribe
@@ -5628,14 +5735,15 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5628
5735
  * (see its doc for the acquire behavior) — supplies this addon's `ctx.logger`.
5629
5736
  * Kept as a method so the two call sites need no change.
5630
5737
  */
5631
- async acquireSessionDecodeRestream(api, deviceId, streamId, tag, hostname, ownerNodeId) {
5738
+ async acquireSessionDecodeRestream(api, deviceId, streamId, tag, hostname, ownerNodeId, detectionIntent) {
5632
5739
  await this.refreshNativeLeaseSettings();
5633
5740
  return acquireSessionDecodeRestreamFrom(api, this.ctx.logger, {
5634
5741
  deviceId,
5635
5742
  streamId,
5636
5743
  tag,
5637
5744
  hostname,
5638
- ownerNodeId
5745
+ ownerNodeId,
5746
+ ...detectionIntent === true ? { detectionIntent: true } : {}
5639
5747
  });
5640
5748
  }
5641
5749
  /**
@@ -5751,10 +5859,12 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5751
5859
  const log = this.ctx.logger.withTags({ deviceId: config.deviceId });
5752
5860
  if (!runner) return () => {};
5753
5861
  const retainedFrames = this.retainedFrames;
5862
+ const requestedAt = Date.now();
5754
5863
  const teardown = startGuardedSessionDecode({
5755
5864
  logger: log,
5756
- acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:detect:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
5865
+ acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:detect:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config), true),
5757
5866
  startPump: (source) => {
5867
+ const dialMs = Date.now() - requestedAt;
5758
5868
  log.info("session-decode: detection routed through per-session decode worker (rgb)", { meta: {
5759
5869
  streamId: config.detectionStreamId,
5760
5870
  codec: source.codec
@@ -5773,6 +5883,21 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5773
5883
  return reservation.detection || reservation.motion;
5774
5884
  },
5775
5885
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "detect"),
5886
+ onFirstFrame: (timing) => {
5887
+ const phaseActiveAt = this.lastMotionAt.get(config.deviceId);
5888
+ log.info("session-decode: detection first frame", {
5889
+ tags: { deviceId: config.deviceId },
5890
+ meta: {
5891
+ streamId: config.detectionStreamId,
5892
+ codec: source.codec,
5893
+ dialMs,
5894
+ spawnWaitMs: timing.spawnWaitMs,
5895
+ forkToFrameMs: timing.forkToFrameMs,
5896
+ firstFrameMs: Date.now() - requestedAt,
5897
+ ...phaseActiveAt !== void 0 ? { sincePhaseActiveMs: Date.now() - phaseActiveAt } : {}
5898
+ }
5899
+ });
5900
+ },
5776
5901
  onEnded: () => this.handleDetectionDecodeEnded(config.deviceId),
5777
5902
  onDecodedFrame: (frame, nativeCrop) => {
5778
5903
  const current = reservation;
@@ -5864,6 +5989,11 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5864
5989
  attachment.detectionKeepWarmTimer = null;
5865
5990
  }
5866
5991
  if (attachment.detectionUnsubscribe !== null) return;
5992
+ const adopted = this.adoptWarmDetectionDecode(deviceId, attachment.config);
5993
+ if (adopted) {
5994
+ attachment.detectionUnsubscribe = adopted;
5995
+ return;
5996
+ }
5867
5997
  this.subscribeDetectionFrames(attachment.config).then((unsub) => {
5868
5998
  if (!this.attached.has(deviceId)) {
5869
5999
  unsub?.();
@@ -1,9 +1,9 @@
1
- import { A as defineCustomActions, Dt as object, Et as number, G as pipelineRunnerCapability, H as pickDetailCropConvention, O as customAction, St as boolean, Tt as literal, U as pickNativeLeaseOverride, bt as _enum, et as errMsg, ht as nodePin, j as deriveDetailCropRect, jt as EventCategory, kt as string, ot as BaseAddon, s as DEFAULT_DETAIL_CROP_CONVENTION, u as DEVICE_BACKEND_TO_FORMAT, ut as createEvent, wt as lazy, xt as array } from "../dist-B9_C-QmC.mjs";
2
- import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-BucTbe9v.mjs";
3
- import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-CQHY2NeB.mjs";
1
+ import { At as string, Ct as boolean, Dt as number, Et as literal, K as pipelineRunnerCapability, M as deriveDetailCropRect, Mt as EventCategory, Ot as object, St as array, Tt as lazy, U as pickDetailCropConvention, W as pickNativeLeaseOverride, dt as createEvent, gt as nodePin, j as defineCustomActions, k as customAction, s as DEFAULT_DETAIL_CROP_CONVENTION, st as BaseAddon, tt as errMsg, u as DEVICE_BACKEND_TO_FORMAT, xt as _enum } from "../dist-BEbyhqoG.mjs";
2
+ import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-BNyf5yEt.mjs";
3
+ import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-CBg9bqCs.mjs";
4
4
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
5
5
  import { t as getSharp } from "../lazy-sharp-B-mb8uWx.mjs";
6
- import { n as profileForStreamId, t as isRemoteRestream } from "../remote-restream-BeHi78PZ.mjs";
6
+ import { i as withDetectionIntent, n as profileForStreamId, t as isRemoteRestream } from "../remote-restream-Ci7RXNGb.mjs";
7
7
  import sharp from "sharp";
8
8
  import { fork } from "node:child_process";
9
9
  import { fileURLToPath } from "node:url";
@@ -1079,6 +1079,7 @@ function startSessionDecodePump(deps) {
1079
1079
  releaseSpawnSlot = null;
1080
1080
  release();
1081
1081
  };
1082
+ const pumpStartedAt = Date.now();
1082
1083
  const pump = async () => {
1083
1084
  try {
1084
1085
  if (deps.acquireSpawnSlot) {
@@ -1088,11 +1089,16 @@ function startSessionDecodePump(deps) {
1088
1089
  return;
1089
1090
  }
1090
1091
  }
1092
+ const spawnGrantedAt = Date.now();
1091
1093
  let firstFrameSeen = false;
1092
1094
  for await (const frame of iterator) {
1093
1095
  if (!firstFrameSeen) {
1094
1096
  firstFrameSeen = true;
1095
1097
  releaseSpawnSlotOnce();
1098
+ deps.onFirstFrame?.({
1099
+ spawnWaitMs: spawnGrantedAt - pumpStartedAt,
1100
+ forkToFrameMs: Date.now() - spawnGrantedAt
1101
+ });
1096
1102
  }
1097
1103
  if (stopped) {
1098
1104
  frame.image.close();
@@ -4025,7 +4031,7 @@ function resolveOwnerRestreamHost(frameSource, hubUrl) {
4025
4031
  * thin delegator to this function — its two call sites are unchanged.
4026
4032
  */
4027
4033
  async function acquireSessionDecodeRestreamFrom(api, logger, params) {
4028
- const { deviceId, streamId, tag, hostname, ownerNodeId } = params;
4034
+ const { deviceId, streamId, tag, hostname, ownerNodeId, detectionIntent } = params;
4029
4035
  const pin = ownerNodeId !== void 0 ? nodePin(ownerNodeId) : void 0;
4030
4036
  const profile = profileForStreamId(await api.streamBroker.listAllProfileSlots.query(void 0, pin), deviceId, streamId);
4031
4037
  if (profile === null) {
@@ -4045,7 +4051,7 @@ async function acquireSessionDecodeRestreamFrom(api, logger, params) {
4045
4051
  }, pin);
4046
4052
  const codec = rtpSource.videoCodec === "H265" ? "h265" : "h264";
4047
4053
  const source = {
4048
- restreamUrl: rtpSource.url,
4054
+ restreamUrl: detectionIntent ? withDetectionIntent(rtpSource.url) : rtpSource.url,
4049
4055
  deviceId,
4050
4056
  codec
4051
4057
  };
@@ -4112,6 +4118,12 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4112
4118
  */
4113
4119
  retainedFrames = null;
4114
4120
  attached = /* @__PURE__ */ new Map();
4121
+ /**
4122
+ * Detection decode workers parked across a detach, keyed by deviceId — see
4123
+ * {@link WarmDetachedDecode}. Bounded by construction: at most one entry per
4124
+ * camera, each self-expiring after {@link DETECTION_KEEPWARM_MS}.
4125
+ */
4126
+ warmDetachedDetection = /* @__PURE__ */ new Map();
4115
4127
  nodeId = "unknown";
4116
4128
  metricsSnapshotTimer = null;
4117
4129
  unsubMotionEvents = null;
@@ -4339,6 +4351,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4339
4351
  attachment.motionUnsubscribe?.();
4340
4352
  attachment.detectionUnsubscribe?.();
4341
4353
  }
4354
+ for (const deviceId of [...this.warmDetachedDetection.keys()]) this.dropWarmDetectionDecode(deviceId, "shutdown");
4342
4355
  this.attached.clear();
4343
4356
  this.lastMotionFrameAt.clear();
4344
4357
  this.nativeCropRegistry.clear();
@@ -5042,7 +5055,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5042
5055
  return { success: true };
5043
5056
  }
5044
5057
  async detachCamera(input) {
5045
- this.detachInternal(input.deviceId);
5058
+ this.detachInternal(input.deviceId, true);
5046
5059
  return { success: true };
5047
5060
  }
5048
5061
  async reportMotion(input) {
@@ -5437,7 +5450,10 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5437
5450
  */
5438
5451
  handleDetectionDecodeEnded(deviceId) {
5439
5452
  const attachment = this.attached.get(deviceId);
5440
- if (!attachment) return;
5453
+ if (!attachment) {
5454
+ this.dropWarmDetectionDecode(deviceId, "worker-died-while-parked");
5455
+ return;
5456
+ }
5441
5457
  const log = this.ctx.logger.withTags({ deviceId });
5442
5458
  attachment.detectionUnsubscribe?.();
5443
5459
  attachment.detectionUnsubscribe = null;
@@ -5468,7 +5484,18 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5468
5484
  });
5469
5485
  }, DETECTION_REDIAL_DELAY_MS).unref?.();
5470
5486
  }
5471
- detachInternal(deviceId) {
5487
+ /**
5488
+ * Tear a camera down.
5489
+ *
5490
+ * `keepDecodeWarm` routes the DETECTION decode through the same keep-warm
5491
+ * grace the phase gate uses instead of killing it — see
5492
+ * {@link WarmDetachedDecode}. Set only by the {@link detachCamera} cap entry
5493
+ * point (the orchestrator's motion-cooldown teardown); every internal detach
5494
+ * — an attach replacing a stale config, a decode that died twice, shutdown —
5495
+ * is a hard one, because in each of those cases the worker is either wrong or
5496
+ * already gone.
5497
+ */
5498
+ detachInternal(deviceId, keepDecodeWarm = false) {
5472
5499
  const attachment = this.attached.get(deviceId);
5473
5500
  if (!attachment) return;
5474
5501
  this.clearOnboardAnalyzer(deviceId);
@@ -5477,7 +5504,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5477
5504
  attachment.detectionKeepWarmTimer = null;
5478
5505
  }
5479
5506
  attachment.motionUnsubscribe?.();
5480
- attachment.detectionUnsubscribe?.();
5507
+ if (keepDecodeWarm && attachment.detectionUnsubscribe !== null) this.parkWarmDetectionDecode(deviceId, attachment.config, attachment.detectionUnsubscribe);
5508
+ else attachment.detectionUnsubscribe?.();
5481
5509
  this.lastMotionFrameAt.delete(deviceId);
5482
5510
  this.lastDecodeUnexpectedEndAt.delete(deviceId);
5483
5511
  this.packageGovernor.forget(deviceId);
@@ -5487,7 +5515,86 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5487
5515
  this.lastMotionAt.delete(deviceId);
5488
5516
  this.lastEmittedCameraMetrics.delete(deviceId);
5489
5517
  this.runner?.unregisterCamera(deviceId);
5490
- this.ctx?.logger.info("detachCamera", { tags: { deviceId } });
5518
+ this.ctx?.logger.info("detachCamera", {
5519
+ tags: { deviceId },
5520
+ meta: { decodeKeptWarm: this.warmDetachedDetection.has(deviceId) }
5521
+ });
5522
+ }
5523
+ /**
5524
+ * The decode-relevant identity of a camera's detection decode. Two configs
5525
+ * that agree on all of it can share one worker; anything else must re-fork.
5526
+ * Kept in ONE place so a future decode-shaping option cannot be added to the
5527
+ * pump without the adoption check noticing.
5528
+ */
5529
+ detectionDecodeIdentity(config) {
5530
+ return {
5531
+ streamId: config.detectionStreamId,
5532
+ fps: config.detectionFps,
5533
+ hostname: resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]),
5534
+ ownerNodeId: resolveSessionDecodeOwnerNodeId(config)
5535
+ };
5536
+ }
5537
+ /**
5538
+ * Hold a detached camera's detection decode worker for
5539
+ * {@link DETECTION_KEEPWARM_MS} so the next motion event reuses it.
5540
+ * Replacing an existing park releases the older worker first — two parked
5541
+ * workers for one camera would be two live decodes and two broker leases.
5542
+ */
5543
+ parkWarmDetectionDecode(deviceId, config, unsubscribe) {
5544
+ this.dropWarmDetectionDecode(deviceId, "replaced");
5545
+ const timer = setTimeout(() => {
5546
+ this.warmDetachedDetection.delete(deviceId);
5547
+ unsubscribe();
5548
+ this.ctx?.logger.info("session-decode: warm detection decode expired — worker released", {
5549
+ tags: { deviceId },
5550
+ meta: { keepWarmMs: DETECTION_KEEPWARM_MS }
5551
+ });
5552
+ }, DETECTION_KEEPWARM_MS);
5553
+ timer.unref?.();
5554
+ this.warmDetachedDetection.set(deviceId, {
5555
+ unsubscribe,
5556
+ timer,
5557
+ ...this.detectionDecodeIdentity(config)
5558
+ });
5559
+ }
5560
+ /**
5561
+ * Adopt a parked decode worker for a camera that is attaching again, or
5562
+ * `null` when there is nothing to adopt.
5563
+ *
5564
+ * A parked worker whose identity no longer matches is DROPPED here rather
5565
+ * than left to expire: the caller is about to fork a replacement, and letting
5566
+ * both run would double this camera's decode cost for the rest of the window.
5567
+ */
5568
+ adoptWarmDetectionDecode(deviceId, config) {
5569
+ const warm = this.warmDetachedDetection.get(deviceId);
5570
+ if (!warm) return null;
5571
+ const wanted = this.detectionDecodeIdentity(config);
5572
+ if (warm.streamId !== wanted.streamId || warm.fps !== wanted.fps || warm.hostname !== wanted.hostname || warm.ownerNodeId !== wanted.ownerNodeId) {
5573
+ this.dropWarmDetectionDecode(deviceId, "config-changed");
5574
+ return null;
5575
+ }
5576
+ clearTimeout(warm.timer);
5577
+ this.warmDetachedDetection.delete(deviceId);
5578
+ this.ctx?.logger.info("session-decode: reusing the warm detection decode worker (no re-fork)", {
5579
+ tags: { deviceId },
5580
+ meta: {
5581
+ streamId: warm.streamId,
5582
+ fps: warm.fps
5583
+ }
5584
+ });
5585
+ return warm.unsubscribe;
5586
+ }
5587
+ /** Release a parked worker now. `reason` names why in the log. */
5588
+ dropWarmDetectionDecode(deviceId, reason) {
5589
+ const warm = this.warmDetachedDetection.get(deviceId);
5590
+ if (!warm) return;
5591
+ clearTimeout(warm.timer);
5592
+ this.warmDetachedDetection.delete(deviceId);
5593
+ warm.unsubscribe();
5594
+ this.ctx?.logger.info("session-decode: warm detection decode released", {
5595
+ tags: { deviceId },
5596
+ meta: { reason }
5597
+ });
5491
5598
  }
5492
5599
  /**
5493
5600
  * Synchronously cancel the teardown timer and call the unsubscribe
@@ -5620,14 +5727,15 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5620
5727
  * (see its doc for the acquire behavior) — supplies this addon's `ctx.logger`.
5621
5728
  * Kept as a method so the two call sites need no change.
5622
5729
  */
5623
- async acquireSessionDecodeRestream(api, deviceId, streamId, tag, hostname, ownerNodeId) {
5730
+ async acquireSessionDecodeRestream(api, deviceId, streamId, tag, hostname, ownerNodeId, detectionIntent) {
5624
5731
  await this.refreshNativeLeaseSettings();
5625
5732
  return acquireSessionDecodeRestreamFrom(api, this.ctx.logger, {
5626
5733
  deviceId,
5627
5734
  streamId,
5628
5735
  tag,
5629
5736
  hostname,
5630
- ownerNodeId
5737
+ ownerNodeId,
5738
+ ...detectionIntent === true ? { detectionIntent: true } : {}
5631
5739
  });
5632
5740
  }
5633
5741
  /**
@@ -5743,10 +5851,12 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5743
5851
  const log = this.ctx.logger.withTags({ deviceId: config.deviceId });
5744
5852
  if (!runner) return () => {};
5745
5853
  const retainedFrames = this.retainedFrames;
5854
+ const requestedAt = Date.now();
5746
5855
  const teardown = startGuardedSessionDecode({
5747
5856
  logger: log,
5748
- acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:detect:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
5857
+ acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:detect:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config), true),
5749
5858
  startPump: (source) => {
5859
+ const dialMs = Date.now() - requestedAt;
5750
5860
  log.info("session-decode: detection routed through per-session decode worker (rgb)", { meta: {
5751
5861
  streamId: config.detectionStreamId,
5752
5862
  codec: source.codec
@@ -5765,6 +5875,21 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5765
5875
  return reservation.detection || reservation.motion;
5766
5876
  },
5767
5877
  acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "detect"),
5878
+ onFirstFrame: (timing) => {
5879
+ const phaseActiveAt = this.lastMotionAt.get(config.deviceId);
5880
+ log.info("session-decode: detection first frame", {
5881
+ tags: { deviceId: config.deviceId },
5882
+ meta: {
5883
+ streamId: config.detectionStreamId,
5884
+ codec: source.codec,
5885
+ dialMs,
5886
+ spawnWaitMs: timing.spawnWaitMs,
5887
+ forkToFrameMs: timing.forkToFrameMs,
5888
+ firstFrameMs: Date.now() - requestedAt,
5889
+ ...phaseActiveAt !== void 0 ? { sincePhaseActiveMs: Date.now() - phaseActiveAt } : {}
5890
+ }
5891
+ });
5892
+ },
5768
5893
  onEnded: () => this.handleDetectionDecodeEnded(config.deviceId),
5769
5894
  onDecodedFrame: (frame, nativeCrop) => {
5770
5895
  const current = reservation;
@@ -5856,6 +5981,11 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5856
5981
  attachment.detectionKeepWarmTimer = null;
5857
5982
  }
5858
5983
  if (attachment.detectionUnsubscribe !== null) return;
5984
+ const adopted = this.adoptWarmDetectionDecode(deviceId, attachment.config);
5985
+ if (adopted) {
5986
+ attachment.detectionUnsubscribe = adopted;
5987
+ return;
5988
+ }
5859
5989
  this.subscribeDetectionFrames(attachment.config).then((unsub) => {
5860
5990
  if (!this.attached.has(deviceId)) {
5861
5991
  unsub?.();