@camstack/addon-pipeline 1.2.228 → 1.2.231

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 (123) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-D0Em2AXV.js → default-detection-model-C7fsHtHQ.js} +1 -1
  4. package/dist/{default-detection-model-C4C723aL.mjs → default-detection-model-DC4yVaFM.mjs} +1 -1
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-Dcp-eVbN.mjs → dist-DTPYOq7J.mjs} +274 -9
  8. package/dist/{dist-Dwhv3eOj.js → dist-p5lZW8pr.js} +279 -8
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-CSDLSs7t.js → node-CGVUk5S9.js} +1 -1
  12. package/dist/{node-ClxCG7kX.mjs → node-Cz92fVGW.mjs} +1 -1
  13. package/dist/pipeline-runner/index.js +4 -4
  14. package/dist/pipeline-runner/index.mjs +4 -4
  15. package/dist/{process-memory-CJ9kKdO6.mjs → process-memory-BwijtYhM.mjs} +1 -1
  16. package/dist/{process-memory-DYCrYZZl.js → process-memory-COypUeEc.js} +1 -1
  17. package/dist/recorder/index.js +3 -3
  18. package/dist/recorder/index.mjs +3 -3
  19. package/dist/{restream-intent-B1Difcnq.js → restream-intent-C4adO0P8.js} +5 -0
  20. package/dist/{restream-intent-DWe2kInY.mjs → restream-intent-MeJhGCK9.mjs} +5 -0
  21. package/dist/{segment-demux-js-CelIP7H_.js → segment-demux-js-B-yzHYVc.js} +1 -1
  22. package/dist/{segment-demux-js-BWZQJ1AS.mjs → segment-demux-js-CyaNDtrw.mjs} +1 -1
  23. package/dist/stream-broker/_stub.js +2 -2
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BOpWY0lf.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B9KO-igY.mjs} +3 -3
  25. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Bc-sSkWV.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-mnvO611B.mjs} +1 -1
  26. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B84T0KI-.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-JoG-yDpV.mjs} +1 -1
  27. package/dist/stream-broker/demux-worker-child.js +1 -1
  28. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  29. package/dist/stream-broker/{hostInit-D79Uznvf.mjs → hostInit-CDwsQyIR.mjs} +3 -3
  30. package/dist/stream-broker/index.js +1380 -90
  31. package/dist/stream-broker/index.mjs +1380 -90
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/package.json +1 -1
  34. package/python/inference_pool.py +39 -9
  35. package/python/test_inference_pool_ov_requests.py +79 -0
  36. package/python/__pycache__/inference_pool.cpython-312.pyc +0 -0
  37. package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
  38. package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
  39. package/python/__pycache__/test_inference_pool_backpressure.cpython-312-pytest-9.1.1.pyc +0 -0
  40. package/python/__pycache__/test_inference_pool_backpressure.cpython-312.pyc +0 -0
  41. package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
  42. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-312-pytest-9.1.1.pyc +0 -0
  43. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-312.pyc +0 -0
  44. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
  45. package/python/__pycache__/test_inference_pool_deadline.cpython-314.pyc +0 -0
  46. package/python/__pycache__/test_inference_pool_device_selection.cpython-312-pytest-9.1.1.pyc +0 -0
  47. package/python/__pycache__/test_inference_pool_device_selection.cpython-312.pyc +0 -0
  48. package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
  49. package/python/__pycache__/test_inference_pool_layout.cpython-312-pytest-9.1.1.pyc +0 -0
  50. package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
  51. package/python/__pycache__/test_inference_pool_memstats.cpython-312-pytest-9.1.1.pyc +0 -0
  52. package/python/__pycache__/test_inference_pool_memstats.cpython-312.pyc +0 -0
  53. package/python/__pycache__/test_inference_pool_memstats.cpython-314.pyc +0 -0
  54. package/python/__pycache__/test_inference_pool_ov_ppp.cpython-312-pytest-9.1.1.pyc +0 -0
  55. package/python/__pycache__/test_inference_pool_ov_ppp.cpython-312.pyc +0 -0
  56. package/python/__pycache__/test_inference_pool_ov_ppp.cpython-314.pyc +0 -0
  57. package/python/__pycache__/test_inference_pool_preprocess.cpython-312-pytest-9.1.1.pyc +0 -0
  58. package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
  59. package/python/__pycache__/test_inference_pool_reconfigure.cpython-314.pyc +0 -0
  60. package/python/__pycache__/test_inference_pool_static_batch.cpython-312-pytest-9.1.1.pyc +0 -0
  61. package/python/__pycache__/test_inference_pool_static_batch.cpython-312.pyc +0 -0
  62. package/python/__pycache__/test_inference_pool_static_batch.cpython-314.pyc +0 -0
  63. package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
  64. package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
  65. package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
  66. package/python/postprocessors/__pycache__/__init__.cpython-314.pyc +0 -0
  67. package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
  68. package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
  69. package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
  70. package/python/postprocessors/__pycache__/arcface.cpython-314.pyc +0 -0
  71. package/python/postprocessors/__pycache__/clip.cpython-312.pyc +0 -0
  72. package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
  73. package/python/postprocessors/__pycache__/clip.cpython-314.pyc +0 -0
  74. package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
  75. package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
  76. package/python/postprocessors/__pycache__/ctc.cpython-314.pyc +0 -0
  77. package/python/postprocessors/__pycache__/plate_slots.cpython-312.pyc +0 -0
  78. package/python/postprocessors/__pycache__/plate_slots.cpython-314.pyc +0 -0
  79. package/python/postprocessors/__pycache__/rfdetr.cpython-312.pyc +0 -0
  80. package/python/postprocessors/__pycache__/rfdetr.cpython-314.pyc +0 -0
  81. package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
  82. package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
  83. package/python/postprocessors/__pycache__/saliency.cpython-314.pyc +0 -0
  84. package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
  85. package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
  86. package/python/postprocessors/__pycache__/scrfd.cpython-314.pyc +0 -0
  87. package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
  88. package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
  89. package/python/postprocessors/__pycache__/softmax.cpython-314.pyc +0 -0
  90. package/python/postprocessors/__pycache__/ssd.cpython-312.pyc +0 -0
  91. package/python/postprocessors/__pycache__/ssd.cpython-313.pyc +0 -0
  92. package/python/postprocessors/__pycache__/ssd.cpython-314.pyc +0 -0
  93. package/python/postprocessors/__pycache__/test_clip.cpython-312-pytest-9.1.1.pyc +0 -0
  94. package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
  95. package/python/postprocessors/__pycache__/test_ctc.cpython-312-pytest-9.1.1.pyc +0 -0
  96. package/python/postprocessors/__pycache__/test_ctc.cpython-312.pyc +0 -0
  97. package/python/postprocessors/__pycache__/test_ctc.cpython-313-pytest-9.1.1.pyc +0 -0
  98. package/python/postprocessors/__pycache__/test_ctc.cpython-313.pyc +0 -0
  99. package/python/postprocessors/__pycache__/test_ctc.cpython-314.pyc +0 -0
  100. package/python/postprocessors/__pycache__/test_plate_slots.cpython-312-pytest-9.1.1.pyc +0 -0
  101. package/python/postprocessors/__pycache__/test_rfdetr.cpython-312-pytest-9.1.1.pyc +0 -0
  102. package/python/postprocessors/__pycache__/test_rfdetr.cpython-314.pyc +0 -0
  103. package/python/postprocessors/__pycache__/test_saliency.cpython-312-pytest-9.1.1.pyc +0 -0
  104. package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
  105. package/python/postprocessors/__pycache__/test_saliency.cpython-314.pyc +0 -0
  106. package/python/postprocessors/__pycache__/test_softmax.cpython-312-pytest-9.1.1.pyc +0 -0
  107. package/python/postprocessors/__pycache__/test_softmax.cpython-314.pyc +0 -0
  108. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-312-pytest-9.1.1.pyc +0 -0
  109. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-314.pyc +0 -0
  110. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-39.pyc +0 -0
  111. package/python/postprocessors/__pycache__/test_yolonas.cpython-312-pytest-9.1.1.pyc +0 -0
  112. package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
  113. package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
  114. package/python/postprocessors/__pycache__/yamnet.cpython-314.pyc +0 -0
  115. package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
  116. package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
  117. package/python/postprocessors/__pycache__/yolo.cpython-314.pyc +0 -0
  118. package/python/postprocessors/__pycache__/yolo.cpython-39.pyc +0 -0
  119. package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
  120. package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
  121. package/python/postprocessors/__pycache__/yolo_seg.cpython-314.pyc +0 -0
  122. package/python/postprocessors/__pycache__/yolonas.cpython-312.pyc +0 -0
  123. package/python/postprocessors/__pycache__/yolonas.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-Dwhv3eOj.js");
7
- const require_node = require("../node-CSDLSs7t.js");
8
- const require_restream_intent = require("../restream-intent-B1Difcnq.js");
6
+ const require_dist = require("../dist-p5lZW8pr.js");
7
+ const require_node = require("../node-CGVUk5S9.js");
8
+ const require_restream_intent = require("../restream-intent-C4adO0P8.js");
9
9
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
10
10
  const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
11
- const require_segment_demux_js = require("../segment-demux-js-CelIP7H_.js");
11
+ const require_segment_demux_js = require("../segment-demux-js-B-yzHYVc.js");
12
12
  let node_os = require("node:os");
13
13
  node_os = require_chunk.__toESM(node_os);
14
14
  let node_crypto = require("node:crypto");
@@ -21,6 +21,7 @@ let node_child_process = require("node:child_process");
21
21
  let node_fs_promises = require("node:fs/promises");
22
22
  let node_url = require("node:url");
23
23
  let node_module = require("node:module");
24
+ let _camstack_system_addon_utils = require("@camstack/system/addon-utils");
24
25
  let node_net = require("node:net");
25
26
  node_net = require_chunk.__toESM(node_net);
26
27
  let node_dgram = require("node:dgram");
@@ -560,6 +561,240 @@ function rankDecodeHwAccels(preferred, supportedMethods) {
560
561
  return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
561
562
  }
562
563
  //#endregion
564
+ //#region src/stream-broker/stream-broker/egress-source-readiness.ts
565
+ /**
566
+ * Whether waiting on this source can achieve anything — the three-way answer
567
+ * `waitForSourceMedia` consumes.
568
+ *
569
+ * The subtlety that cost a measured 15 s per cold Alexa open on 2026-09-13: a
570
+ * lazy publisher materialises because someone DIALS it. The source broker's own
571
+ * `redial` / `kickReconnect` both refuse without demand, by design — a broker
572
+ * with no consumer must not wake a camera. So for a cold, idle source the
573
+ * ffmpeg child IS the demand, and withholding it waits for media that only
574
+ * starts because of the thing being withheld. The live log of that mistake:
575
+ *
576
+ * 19:04:27 waited for the SOURCE {outcome=timeout waitedMs=15005 ready=false}
577
+ * 19:04:30 the child died before the decoder — retrying in SOFTWARE
578
+ * 19:04:32 rfc4571: publisher materialized {materializeMs=5798}
579
+ *
580
+ * Fifteen seconds of nothing, then the spawn, then the wake it had triggered.
581
+ *
582
+ * So: wait only when a materialize is ALREADY in flight — a second consumer
583
+ * arriving while the first is waking the camera, which is the case the wait was
584
+ * really for. A cold idle source answers `null`: not "no media", but "waiting
585
+ * here cannot help", and the caller spawns at once.
586
+ */
587
+ function sourceWaitState(input) {
588
+ if (input.hasMedia) return true;
589
+ if (input.materializing) return false;
590
+ return null;
591
+ }
592
+ async function waitForSourceMedia(input) {
593
+ const started = input.now();
594
+ const first = input.hasMedia();
595
+ if (first === null) return {
596
+ ready: false,
597
+ waitedMs: 0,
598
+ outcome: "unknown"
599
+ };
600
+ if (first) return {
601
+ ready: true,
602
+ waitedMs: 0,
603
+ outcome: "live"
604
+ };
605
+ for (;;) {
606
+ const elapsed = input.now() - started;
607
+ if (elapsed >= input.timeoutMs) return {
608
+ ready: false,
609
+ waitedMs: elapsed,
610
+ outcome: "timeout"
611
+ };
612
+ await input.sleep(input.pollMs);
613
+ const state = input.hasMedia();
614
+ const waitedMs = input.now() - started;
615
+ if (state === null) return {
616
+ ready: false,
617
+ waitedMs,
618
+ outcome: "unknown"
619
+ };
620
+ if (state) return {
621
+ ready: true,
622
+ waitedMs,
623
+ outcome: "materialized"
624
+ };
625
+ }
626
+ }
627
+ //#endregion
628
+ //#region src/stream-broker/stream-broker/stream-health-scope.ts
629
+ /**
630
+ * WHICH streams a health transition is worth telling the operator about.
631
+ *
632
+ * `stream.online` / `stream.offline` are operator-facing: they reach the
633
+ * notification rules and land on a phone. So they must be facts about a
634
+ * CAMERA, and an egress stream is not one — it is the private pipe an exporter
635
+ * asks the broker to build for one consumer session and drops when that session
636
+ * ends. Its whole life is an implementation detail of somebody watching.
637
+ *
638
+ * Measured on this hub 2026-09-13: every Alexa session on the baby monitor
639
+ * produced a `stream.online` and a `stream.offline` pair against
640
+ * `640/egress:8e96e97a…`, and the operator read them as the battery camera
641
+ * flapping during its wake. Nothing was flapping — that is just what an egress
642
+ * pipe does, once per session, by design.
643
+ *
644
+ * The doctrine already existed one layer down: an egress is published with
645
+ * `autoEligible: false` so "a private egress can never become a camera's
646
+ * published stream". It simply had not reached the event path.
647
+ *
648
+ * A DERIVED stream is different and stays in scope: the operator configured it,
649
+ * it is meant to persist, and its going offline is news.
650
+ */
651
+ /** Private, per-session egress pipes. See {@link healthEventIsOperatorFacing}. */
652
+ var PRIVATE_STREAM_PREFIXES = ["egress:"];
653
+ function healthEventIsOperatorFacing(camStreamId) {
654
+ return !PRIVATE_STREAM_PREFIXES.some((prefix) => camStreamId.startsWith(prefix));
655
+ }
656
+ //#endregion
657
+ //#region src/stream-broker/stream-broker/prestream-eligibility.ts
658
+ function prestreamEligible(input) {
659
+ if (input.batteryOperated === null || input.declaresSignals === null) return {
660
+ eligible: false,
661
+ reason: "unknown"
662
+ };
663
+ if (!input.batteryOperated) return {
664
+ eligible: false,
665
+ reason: "not-battery"
666
+ };
667
+ if (!input.declaresSignals) return {
668
+ eligible: false,
669
+ reason: "no-signals-cap"
670
+ };
671
+ return { eligible: true };
672
+ }
673
+ //#endregion
674
+ //#region src/stream-broker/stream-broker/prestream-signal-selection.ts
675
+ function nextEnabledSignals(input) {
676
+ const base = input.stored ?? input.declared;
677
+ const set = new Set(base);
678
+ if (input.enabled) set.add(input.code);
679
+ else set.delete(input.code);
680
+ const known = input.declared.filter((c) => set.has(c));
681
+ const extra = [...set].filter((c) => !input.declared.includes(c));
682
+ return [...known, ...extra];
683
+ }
684
+ //#endregion
685
+ //#region src/stream-broker/stream-broker/wake-hold.ts
686
+ /**
687
+ * The HOLD a camera's own signal buys, and what it may not buy.
688
+ *
689
+ * A stream a consumer merely watches is not a reason to wake a battery camera —
690
+ * a Frigate-style puller holds a session open for ever, and treating that as
691
+ * demand keeps the camera awake for ever, which is the whole point of the
692
+ * battery (D173). So a prestream consumer is FED without being INTENT, and the
693
+ * only thing that dials is a signal the camera reported about itself.
694
+ *
695
+ * This module is the timing half of that, and it is pure so the arithmetic can
696
+ * be argued with. Three rules:
697
+ *
698
+ * - **A hold EXTENDS, it never stacks.** Continuous motion keeps a camera up
699
+ * and it sleeps `holdMs` after the LAST signal, not after the sum of them.
700
+ * A counter or a queue would turn a busy afternoon into an hour of wake.
701
+ * - **A run is bounded ABSOLUTELY, from its first signal.** A stuck PIR
702
+ * reporting every second would otherwise extend the hold indefinitely and
703
+ * drain the battery — the one failure this whole design exists to prevent.
704
+ * The same re-arm-under-a-ceiling shape D371 gives `recording-signal`.
705
+ * - **A deadline, not a chain of timers.** The state is one number; the timer
706
+ * only decides when to LOOK at it. A chain would drift and would have to be
707
+ * cancelled correctly on every path.
708
+ */
709
+ /** How long a camera stays awake after the LAST signal. The operator's, per
710
+ * camera; this is the default the broker ships. */
711
+ var DEFAULT_WAKE_HOLD_MS = 15e3;
712
+ /**
713
+ * Ceiling on ONE run, measured from its first signal.
714
+ *
715
+ * Five minutes. Long enough that a genuine sustained event — a person moving
716
+ * around a garden — is covered end to end; short enough that a sensor stuck
717
+ * ON costs one five-minute wake rather than a night.
718
+ */
719
+ var WAKE_HOLD_MAX_RUN_MS = 5 * 6e4;
720
+ /**
721
+ * How long a camera is left alone after a run hit the ceiling.
722
+ *
723
+ * Without this the ceiling is cosmetic, and the test that found it says why: a
724
+ * run ends exactly AT the ceiling, so the very next signal from a sensor that
725
+ * never stops re-arms a fresh five minutes. Five minutes awake, an instant
726
+ * asleep, five minutes awake — all night, which is the precise failure the
727
+ * ceiling was written to prevent.
728
+ *
729
+ * So a capped run buys silence. Ten minutes: long enough that a stuck sensor
730
+ * costs a third of its time rather than all of it, short enough that a genuine
731
+ * long event — someone working in the garden — resumes within a few minutes
732
+ * instead of being locked out for an hour.
733
+ *
734
+ * Signals during the quarantine are REFUSED and counted, not queued: a queue
735
+ * would fire them all the moment it lifts, which is the same drain arriving
736
+ * late.
737
+ */
738
+ var WAKE_QUARANTINE_AFTER_CAP_MS = 10 * 6e4;
739
+ var NO_HOLD = {
740
+ runStartedAt: 0,
741
+ until: 0,
742
+ signals: 0,
743
+ quarantinedUntil: 0
744
+ };
745
+ /**
746
+ * Absorb one signal.
747
+ *
748
+ * A signal arriving with the run already at its ceiling does NOT extend it —
749
+ * and does not start a new one either, because a new run measured from now
750
+ * would be the same unbounded extension wearing a different name. It is
751
+ * reported as `capped` so the operator can see a sensor that will not stop.
752
+ */
753
+ function absorbSignal(input) {
754
+ const { current, now, holdMs } = input;
755
+ if (!(current.until > now && current.runStartedAt > 0)) {
756
+ if (current.quarantinedUntil > now) return {
757
+ hold: {
758
+ ...current,
759
+ signals: current.signals + 1
760
+ },
761
+ outcome: "quarantined"
762
+ };
763
+ return {
764
+ hold: {
765
+ runStartedAt: now,
766
+ until: now + holdMs,
767
+ signals: 1,
768
+ quarantinedUntil: 0
769
+ },
770
+ outcome: "armed"
771
+ };
772
+ }
773
+ if (now - current.runStartedAt >= 3e5) return {
774
+ hold: {
775
+ ...current,
776
+ signals: current.signals + 1,
777
+ quarantinedUntil: now + WAKE_QUARANTINE_AFTER_CAP_MS
778
+ },
779
+ outcome: "capped"
780
+ };
781
+ const ceiling = current.runStartedAt + WAKE_HOLD_MAX_RUN_MS;
782
+ const until = Math.min(now + holdMs, ceiling);
783
+ return {
784
+ hold: {
785
+ ...current,
786
+ until,
787
+ signals: current.signals + 1,
788
+ ...until === ceiling ? { quarantinedUntil: ceiling + WAKE_QUARANTINE_AFTER_CAP_MS } : {}
789
+ },
790
+ outcome: "extended"
791
+ };
792
+ }
793
+ /** Whether the hold still keeps the camera awake at `now`. */
794
+ function holdIsLive(hold, now) {
795
+ return hold.until > now;
796
+ }
797
+ //#endregion
563
798
  //#region src/stream-broker/battery-wake-on-play.ts
564
799
  /**
565
800
  * Wake-on-play — pressing play on a sleeping battery camera wakes it, and the
@@ -1248,7 +1483,7 @@ var ADTS_SAMPLE_RATES = [
1248
1483
  /** AAC-LC AudioObjectType for the 2-byte AudioSpecificConfig. */
1249
1484
  var AAC_LC_OBJECT_TYPE = 2;
1250
1485
  /** Grace before escalating SIGTERM → SIGKILL on a lingering child. */
1251
- var KILL_GRACE_MS = 500;
1486
+ var KILL_GRACE_MS$1 = 500;
1252
1487
  /**
1253
1488
  * Parse the 7-byte ADTS header of an AAC frame into the audio-init params the
1254
1489
  * broker's push ingress needs. Returns `null` when the buffer is too short or
@@ -1442,7 +1677,7 @@ var FfmpegPushReader = class {
1442
1677
  if (pid !== void 0 && child.exitCode === null && child.signalCode === null) try {
1443
1678
  child.kill("SIGKILL");
1444
1679
  } catch {}
1445
- }, KILL_GRACE_MS).unref?.();
1680
+ }, KILL_GRACE_MS$1).unref?.();
1446
1681
  }
1447
1682
  };
1448
1683
  //#endregion
@@ -2894,6 +3129,26 @@ var RtspRestreamer = class {
2894
3129
  getSessionCount() {
2895
3130
  return this.sessions.size;
2896
3131
  }
3132
+ /**
3133
+ * Sessions that count as LIVE INTENT — everything except a prestream dial.
3134
+ *
3135
+ * The split exists so a Frigate-style puller can hold a session open for ever
3136
+ * against a battery camera without keeping it awake (D173). For a broker with
3137
+ * no prestream session this equals {@link getSessionCount} exactly, which is
3138
+ * why nothing else changed behaviour.
3139
+ */
3140
+ getLiveIntentSessionCount() {
3141
+ let n = 0;
3142
+ for (const session of this.sessions.values()) if (!session.isPrestreamIntent()) n += 1;
3143
+ return n;
3144
+ }
3145
+ /** Sessions asking only for the picture. Drives the prestream pump, nothing
3146
+ * else — it must never reach a dial decision. */
3147
+ getPrestreamSessionCount() {
3148
+ let n = 0;
3149
+ for (const session of this.sessions.values()) if (session.isPrestreamIntent()) n += 1;
3150
+ return n;
3151
+ }
2897
3152
  /** Per-session diagnostic snapshots consumed by `StreamBroker.listClients`. */
2898
3153
  listSessionInfos() {
2899
3154
  return [...this.sessions.values()].map((s) => s.getInfo());
@@ -3646,6 +3901,16 @@ var RtspSession = class {
3646
3901
  return this.declaration.intent === "detection";
3647
3902
  }
3648
3903
  /**
3904
+ * Whether this dial asked for the STREAM without asking for the CAMERA.
3905
+ *
3906
+ * The one question the demand split turns on: a prestream session is FED but
3907
+ * is not INTENT, so it never dials a sleeping camera. See
3908
+ * `PRESTREAM_PATH_SUFFIX`.
3909
+ */
3910
+ isPrestreamIntent() {
3911
+ return this.declaration.intent === "prestream";
3912
+ }
3913
+ /**
3649
3914
  * Ms of retained history this session ASKED for on its dial. `0` = none.
3650
3915
  *
3651
3916
  * Read by `RtspRestreamer.servePendingSessionsRtp`, which serves the smaller
@@ -5013,41 +5278,19 @@ function isDecodeBackendFailure(error) {
5013
5278
  return true;
5014
5279
  }
5015
5280
  //#endregion
5016
- //#region src/stream-broker/stream-broker/egress-source-readiness.ts
5017
- async function waitForSourceMedia(input) {
5018
- const started = input.now();
5019
- const first = input.hasMedia();
5020
- if (first === null) return {
5021
- ready: false,
5022
- waitedMs: 0,
5023
- outcome: "unknown"
5024
- };
5025
- if (first) return {
5026
- ready: true,
5027
- waitedMs: 0,
5028
- outcome: "live"
5281
+ //#region src/stream-broker/stream-broker/egress-geometry.ts
5282
+ function capAxis(requested, source) {
5283
+ if (requested === void 0) return void 0;
5284
+ if (source === void 0 || source <= 0) return requested;
5285
+ return Math.min(requested, source);
5286
+ }
5287
+ function deliveredGeometry(input) {
5288
+ const width = capAxis(input.requestedWidth, input.sourceWidth);
5289
+ const height = capAxis(input.requestedHeight, input.sourceHeight);
5290
+ return {
5291
+ ...width === void 0 ? {} : { width },
5292
+ ...height === void 0 ? {} : { height }
5029
5293
  };
5030
- for (;;) {
5031
- const elapsed = input.now() - started;
5032
- if (elapsed >= input.timeoutMs) return {
5033
- ready: false,
5034
- waitedMs: elapsed,
5035
- outcome: "timeout"
5036
- };
5037
- await input.sleep(input.pollMs);
5038
- const state = input.hasMedia();
5039
- const waitedMs = input.now() - started;
5040
- if (state === null) return {
5041
- ready: false,
5042
- waitedMs,
5043
- outcome: "unknown"
5044
- };
5045
- if (state) return {
5046
- ready: true,
5047
- waitedMs,
5048
- outcome: "materialized"
5049
- };
5050
- }
5051
5294
  }
5052
5295
  //#endregion
5053
5296
  //#region src/stream-broker/stream-broker/rtsp-host.ts
@@ -5433,6 +5676,17 @@ var SOURCE_MEDIA_WAIT_MS = 15e3;
5433
5676
  /** Poll cadence for that wait. Flat, for the reason a materialize poll is. */
5434
5677
  var SOURCE_MEDIA_POLL_MS = 250;
5435
5678
  /**
5679
+ * How long to wait before re-dialling a source whose input was not there.
5680
+ *
5681
+ * The first spawn is what WAKES a lazy publisher, and the measured wake on this
5682
+ * hub lands between 2.2 s and 7.8 s (`materializeMs`). Three seconds is inside
5683
+ * that band: long enough that a quick wake is already serving, short enough
5684
+ * that the second attempt still beats the consumer's own patience.
5685
+ */
5686
+ var INPUT_RETRY_DELAY_MS = 3e3;
5687
+ /** After the first three, log one keyframe in this many. */
5688
+ var KEYFRAME_LOG_EVERY = 25;
5689
+ /**
5436
5690
  * How long a push child may produce nothing before it is declared dead. The
5437
5691
  * observed failure EXITS in ~2 s and is caught by the error path long before
5438
5692
  * this; the timeout only covers a child that hangs.
@@ -5598,12 +5852,18 @@ var EgressTranscodeManager = class {
5598
5852
  const decodeHwAccel = await this.opts.resolveDecodeHwAccel(deviceId, request.decodeHwAccel);
5599
5853
  const fragments = delivery.transport === "fragments";
5600
5854
  const publishedCamStreamId = `egress:${key.replace(/^egress:/, "")}`;
5855
+ const delivered = deliveredGeometry({
5856
+ requestedWidth: request.encode.video.width,
5857
+ requestedHeight: request.encode.video.height,
5858
+ sourceWidth: source.facts.width,
5859
+ sourceHeight: source.facts.height
5860
+ });
5601
5861
  const pushSink = fragments || request.publishLocally !== true || this.opts.publishPushStream === void 0 ? null : await this.opts.publishPushStream({
5602
5862
  deviceId,
5603
5863
  camStreamId: publishedCamStreamId,
5604
5864
  codec: request.encode.video.codec,
5605
- width: request.encode.video.width ?? 0,
5606
- height: request.encode.video.height ?? 0
5865
+ width: delivered.width ?? 0,
5866
+ height: delivered.height ?? 0
5607
5867
  });
5608
5868
  if (request.publishLocally === true && pushSink === null && !fragments) this.opts.logger.warn("egress transcode: local publication REFUSED — the consumer must dial the url", {
5609
5869
  tags: { deviceId },
@@ -5617,7 +5877,10 @@ var EgressTranscodeManager = class {
5617
5877
  const fragmentSeconds = fragments ? (delivery.fragmentMs ?? 0) / 1e3 : void 0;
5618
5878
  const { sink: _sink, audioSidecar: _audioSidecar, ...plan } = require_dist.invocationFromEncodeProfile({
5619
5879
  profile: {
5620
- video: request.encode.video,
5880
+ video: {
5881
+ ...request.encode.video,
5882
+ ...delivered
5883
+ },
5621
5884
  audio: audio.profileAudio
5622
5885
  },
5623
5886
  sourceCodec: source.codec,
@@ -6049,6 +6312,18 @@ var EgressTranscodeManager = class {
6049
6312
  dts: packet.dts
6050
6313
  }
6051
6314
  });
6315
+ } else if (packet.keyframe === true) {
6316
+ keyframesOut += 1;
6317
+ if (keyframesOut <= 3 || keyframesOut % KEYFRAME_LOG_EVERY === 0) logger.info("egress push: keyframe out", {
6318
+ tags: { deviceId },
6319
+ meta: {
6320
+ brokerId: spec.brokerId,
6321
+ camStreamId: spec.publishedCamStreamId,
6322
+ keyframesOut,
6323
+ bytes: packet.data.length,
6324
+ approxRtpPackets: Math.ceil(packet.data.length / 1200)
6325
+ }
6326
+ });
6052
6327
  } else if (packetsOut % PUSH_PACKET_LOG_EVERY === 0) logger.info("egress push: packets still flowing out of the ffmpeg child", {
6053
6328
  tags: { deviceId },
6054
6329
  meta: {
@@ -6106,30 +6381,66 @@ var EgressTranscodeManager = class {
6106
6381
  reader.connect();
6107
6382
  });
6108
6383
  };
6384
+ let keyframesOut = 0;
6109
6385
  let hwAccelAtFault = true;
6110
6386
  return {
6111
6387
  hwAccelAtFault: () => hwAccelAtFault,
6112
6388
  start: async () => {
6113
6389
  const requested = spec.invocation.decodeHwAccel;
6390
+ /** Whether a same-decode retry already produced a live child. */
6391
+ let recoveredOnRetry = false;
6392
+ const inputRetryDelayMs = this.opts.inputRetryDelayMs ?? INPUT_RETRY_DELAY_MS;
6114
6393
  try {
6115
6394
  await spawnAttempt(requested);
6116
6395
  } catch (err) {
6117
6396
  if (requested === null || require_dist.isSoftwareDecode(requested)) throw err;
6118
6397
  const blameBackend = isDecodeBackendFailure(require_dist.errMsg(err));
6119
6398
  hwAccelAtFault = blameBackend;
6120
- logger.warn(blameBackend ? "egress push: hardware decode produced NO packets — retrying in SOFTWARE" : "egress push: the child died before the decoder — retrying in SOFTWARE, backend NOT blamed", {
6121
- tags: { deviceId },
6122
- meta: {
6123
- brokerId: spec.brokerId,
6124
- camStreamId: spec.publishedCamStreamId,
6125
- decodeHwAccel: requested,
6126
- blameBackend,
6127
- error: require_dist.errMsg(err)
6399
+ if (!blameBackend) {
6400
+ logger.info("egress push: the input was not there — retrying the SAME decode", {
6401
+ tags: { deviceId },
6402
+ meta: {
6403
+ brokerId: spec.brokerId,
6404
+ camStreamId: spec.publishedCamStreamId,
6405
+ decodeHwAccel: requested,
6406
+ afterMs: inputRetryDelayMs,
6407
+ error: require_dist.errMsg(err)
6408
+ }
6409
+ });
6410
+ reader?.destroy();
6411
+ await new Promise((resolve) => {
6412
+ setTimeout(resolve, inputRetryDelayMs).unref?.();
6413
+ });
6414
+ try {
6415
+ await spawnAttempt(requested);
6416
+ recoveredOnRetry = true;
6417
+ } catch (retryErr) {
6418
+ logger.warn("egress push: the input was STILL not there — falling to SOFTWARE", {
6419
+ tags: { deviceId },
6420
+ meta: {
6421
+ brokerId: spec.brokerId,
6422
+ camStreamId: spec.publishedCamStreamId,
6423
+ decodeHwAccel: requested,
6424
+ error: require_dist.errMsg(retryErr)
6425
+ }
6426
+ });
6128
6427
  }
6129
- });
6130
- reader?.destroy();
6131
- activeHwAccel = null;
6132
- await spawnAttempt(null);
6428
+ }
6429
+ if (!recoveredOnRetry) {
6430
+ logger.warn(blameBackend ? "egress push: hardware decode produced NO packets — retrying in SOFTWARE" : "egress push: the child died before the decoder — retrying in SOFTWARE, backend NOT blamed", {
6431
+ tags: { deviceId },
6432
+ meta: {
6433
+ brokerId: spec.brokerId,
6434
+ camStreamId: spec.publishedCamStreamId,
6435
+ decodeHwAccel: requested,
6436
+ blameBackend,
6437
+ error: require_dist.errMsg(err)
6438
+ }
6439
+ });
6440
+ reader?.destroy();
6441
+ activeHwAccel = null;
6442
+ await spawnAttempt(null);
6443
+ }
6133
6444
  }
6134
6445
  logger.info("egress transcode: publishing LOCALLY — no rtsp round trip", {
6135
6446
  tags: { deviceId },
@@ -6879,13 +7190,13 @@ var EVENT_CLIP_MAX_HEIGHT = 1080;
6879
7190
  * `pickClipProfile` and the answer reports which was used.
6880
7191
  */
6881
7192
  function pickBestClipProfile(candidates) {
6882
- const usable = candidates.filter((c) => normaliseCodec$1(c.codec) === "h264" && (c.height ?? 0) > 0 && (c.width ?? 0) > 0);
7193
+ const usable = candidates.filter((c) => normaliseCodec$2(c.codec) === "h264" && (c.height ?? 0) > 0 && (c.width ?? 0) > 0);
6883
7194
  if (usable.length === 0) return null;
6884
7195
  const within = usable.filter((c) => (c.height ?? 0) <= EVENT_CLIP_MAX_HEIGHT);
6885
7196
  return [...within.length > 0 ? within : usable].sort((a, b) => within.length > 0 ? (b.height ?? 0) - (a.height ?? 0) : (a.height ?? 0) - (b.height ?? 0))[0]?.profile ?? null;
6886
7197
  }
6887
7198
  /** Publishers spell H.265 four ways; the same normalisation the broker uses. */
6888
- function normaliseCodec$1(codec) {
7199
+ function normaliseCodec$2(codec) {
6889
7200
  return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
6890
7201
  }
6891
7202
  /**
@@ -7331,6 +7642,249 @@ function runGif(input, args) {
7331
7642
  });
7332
7643
  }
7333
7644
  //#endregion
7645
+ //#region src/stream-broker/stream-broker/prestream-encode.ts
7646
+ /**
7647
+ * ONE short-lived ffmpeg turns the prestream's picture into ONE Annex-B IDR.
7648
+ *
7649
+ * ── Why one encode and not a continuous child ─────────────────────────────
7650
+ * The obvious shape is `-loop 1 -framerate 1` and let ffmpeg emit the picture
7651
+ * for as long as the camera is waking. That is a resident child per sleeping
7652
+ * camera, for the whole wake, to redraw a picture that changes at most hourly.
7653
+ * So the child encodes exactly ONE frame (`-frames:v 1`) and exits, the caller
7654
+ * caches the bytes, and {@link PrestreamPump} re-emits them. A new encode
7655
+ * happens only when the picture changes.
7656
+ *
7657
+ * ── Why every frame carries its own parameter sets ────────────────────────
7658
+ * The pump re-emits ONE access unit at 1 fps, so a consumer that attaches on
7659
+ * tick 4 sees its first packet with nothing before it. `-g 1` plus
7660
+ * `repeat-headers=1` makes that packet a complete IDR with SPS/PPS (or
7661
+ * VPS/SPS/PPS) inline, so it decodes within a second of attaching instead of
7662
+ * waiting for a keyframe that will never come.
7663
+ *
7664
+ * ── Why the geometry comes from the plan and nowhere else ─────────────────
7665
+ * `planPrestream` refuses a camera whose geometry nobody has probed, precisely
7666
+ * so this file never has a default to fall back on. `stream-broker.ts` records
7667
+ * what a wrong one does: a fixed 1280x720 placeholder SPS in front of a
7668
+ * camera's real IDR froze Chrome's decoder on the last synthetic frame until
7669
+ * the viewer was recreated by hand. Hence `scale` mode `exact`, the plan's
7670
+ * numbers, and the plan's codec.
7671
+ *
7672
+ * ── Why no text is drawn here ─────────────────────────────────────────────
7673
+ * D118: the hub carries two ffmpeg builds and only one has libfreetype, so
7674
+ * `drawtext` is forbidden. The picture arrives already composed — sharp + an
7675
+ * inline SVG, the house pattern from `snapshot-courtesy.ts`.
7676
+ */
7677
+ /**
7678
+ * Bound on the encode. A single 4 MP still on `ultrafast` is milliseconds; a
7679
+ * child that has not answered in this long is hung, and the prestream would
7680
+ * otherwise hold a promise open for the rest of the process's life.
7681
+ */
7682
+ var PRESTREAM_ENCODE_TIMEOUT_MS = 1e4;
7683
+ /** Grace before escalating SIGTERM → SIGKILL on a child that will not go. */
7684
+ var KILL_GRACE_MS = 500;
7685
+ /** x264/x265 params shared by both codecs: one-frame GOP, headers on it. */
7686
+ var STILL_ENCODER_PARAMS = "keyint=1:repeat-headers=1";
7687
+ /**
7688
+ * The argv. PURE, so the two properties that matter — the geometry is the
7689
+ * plan's, and the frame is a self-contained IDR — are assertable without a
7690
+ * binary.
7691
+ */
7692
+ function buildPrestreamEncodeArgs(plan) {
7693
+ const h265 = plan.codec === "h265";
7694
+ return require_dist.buildFfmpegArgs({
7695
+ logLevel: "error",
7696
+ decodeHwAccel: null,
7697
+ input: {
7698
+ url: "pipe:0",
7699
+ extraArgs: [
7700
+ "-f",
7701
+ "image2pipe",
7702
+ "-framerate",
7703
+ "1"
7704
+ ]
7705
+ },
7706
+ video: {
7707
+ kind: "encode",
7708
+ encoder: h265 ? "libx265" : "libx264",
7709
+ scale: {
7710
+ mode: "exact",
7711
+ width: plan.width,
7712
+ height: plan.height
7713
+ },
7714
+ preset: "ultrafast",
7715
+ ...h265 ? {} : { tune: "stillimage" },
7716
+ pixelFormat: "yuv420p",
7717
+ fps: 1,
7718
+ gopFrames: 1,
7719
+ bf: 0
7720
+ },
7721
+ audio: { kind: "none" },
7722
+ threadCount: 1,
7723
+ outputArgs: [
7724
+ "-frames:v",
7725
+ "1",
7726
+ h265 ? "-x265-params" : "-x264-params",
7727
+ STILL_ENCODER_PARAMS
7728
+ ],
7729
+ sink: {
7730
+ kind: "stdout",
7731
+ container: h265 ? "hevc" : "h264"
7732
+ }
7733
+ });
7734
+ }
7735
+ /**
7736
+ * Run the encode. Resolves with the Annex-B bytes, rejects with a named reason
7737
+ * — the caller turns a rejection into a refusal log and no prestream, which is
7738
+ * the same outcome as a black screen and never worse than one.
7739
+ */
7740
+ function encodePrestreamIdr(options) {
7741
+ const args = buildPrestreamEncodeArgs(options.plan);
7742
+ const spawnFn = options.spawnFn ?? node_child_process.spawn;
7743
+ const tags = options.deviceId === void 0 ? {} : { tags: { deviceId: options.deviceId } };
7744
+ return new Promise((resolve, reject) => {
7745
+ let child;
7746
+ try {
7747
+ child = spawnFn(options.ffmpegPath, args);
7748
+ } catch (err) {
7749
+ reject(/* @__PURE__ */ new Error(`prestream encode could not spawn ffmpeg: ${require_dist.errMsg(err)}`));
7750
+ return;
7751
+ }
7752
+ const chunks = [];
7753
+ let stderrTail = "";
7754
+ let settled = false;
7755
+ const finish = (outcome) => {
7756
+ if (settled) return;
7757
+ settled = true;
7758
+ clearTimeout(timer);
7759
+ if ("error" in outcome) reject(outcome.error);
7760
+ else resolve(outcome.bytes);
7761
+ };
7762
+ const timer = setTimeout(() => {
7763
+ killChild(child);
7764
+ finish({ error: /* @__PURE__ */ new Error(`prestream encode timed out after ${String(PRESTREAM_ENCODE_TIMEOUT_MS)} ms`) });
7765
+ }, PRESTREAM_ENCODE_TIMEOUT_MS);
7766
+ timer.unref?.();
7767
+ child.stdout?.on("data", (chunk) => chunks.push(chunk));
7768
+ child.stderr?.on("data", (chunk) => {
7769
+ stderrTail = `${stderrTail}${chunk.toString("utf8")}`.slice(-2048);
7770
+ });
7771
+ child.stdin?.on("error", () => {});
7772
+ child.on("error", (err) => {
7773
+ finish({ error: /* @__PURE__ */ new Error(`prestream encode failed: ${err.message}`) });
7774
+ });
7775
+ child.on("exit", (code, signal) => {
7776
+ if (code !== 0) {
7777
+ finish({ error: /* @__PURE__ */ new Error(`prestream encode exited ${String(code ?? signal)}: ${stderrTail.trim() || "no stderr"}`) });
7778
+ return;
7779
+ }
7780
+ const bytes = Buffer.concat(chunks);
7781
+ if (bytes.length === 0) {
7782
+ finish({ error: /* @__PURE__ */ new Error("prestream encode produced no bytes") });
7783
+ return;
7784
+ }
7785
+ options.logger?.debug("prestream: picture encoded", {
7786
+ ...tags,
7787
+ meta: {
7788
+ codec: options.plan.codec,
7789
+ width: options.plan.width,
7790
+ height: options.plan.height,
7791
+ bytes: bytes.length
7792
+ }
7793
+ });
7794
+ finish({ bytes });
7795
+ });
7796
+ child.stdin?.end(options.jpeg);
7797
+ });
7798
+ }
7799
+ function killChild(child) {
7800
+ if (child.exitCode !== null || child.signalCode !== null) return;
7801
+ child.kill("SIGTERM");
7802
+ setTimeout(() => {
7803
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
7804
+ }, KILL_GRACE_MS).unref?.();
7805
+ }
7806
+ //#endregion
7807
+ //#region src/stream-broker/stream-broker/prestream-picture.ts
7808
+ /**
7809
+ * The PICTURE the prestream shows: the camera's own last frame under a "Waking"
7810
+ * treatment, or — when there is none — a generated card carrying its name.
7811
+ *
7812
+ * ── Why a fallback exists at all ──────────────────────────────────────────
7813
+ * The snapshot cache is RAM-only. After a hub restart a sleeping battery camera
7814
+ * has no cached frame, and `getSnapshot` without `force` will not wake it to
7815
+ * make one (`force` is an OPERATOR signal — a background caller that sets it
7816
+ * wakes the camera it was trying not to disturb). So on exactly the cameras
7817
+ * this feature is for, "no picture" is a normal state, not an error. A card
7818
+ * saying WAKING with the camera's name is worse than the real frame and much
7819
+ * better than black — and it is what makes the broker servable inside Amazon's
7820
+ * six-second SDP deadline on a cold hub.
7821
+ *
7822
+ * ── Why the source is reported ────────────────────────────────────────────
7823
+ * The two look identical to a consumer and are very different to an operator:
7824
+ * one means the cache is warm, the other means it is cold. The caller logs
7825
+ * which was used, per camera.
7826
+ *
7827
+ * Pure input → bytes. No snapshot fetch, no device lookup, no I/O of its own —
7828
+ * the caller does both and hands the results in.
7829
+ */
7830
+ /**
7831
+ * Compose the picture.
7832
+ *
7833
+ * An overlay that cannot be rendered (a truncated or non-decodable cached
7834
+ * frame) falls through to the generated card rather than leaking an unlabelled
7835
+ * stale frame — the same call the snapshot wrapper's `stateImage` makes, for
7836
+ * the same reason.
7837
+ */
7838
+ async function composePrestreamPicture(input) {
7839
+ const tags = input.deviceId === void 0 ? {} : { tags: { deviceId: input.deviceId } };
7840
+ if (input.cachedJpeg !== null && input.cachedJpeg.length > 0) try {
7841
+ return {
7842
+ jpeg: await (0, _camstack_system_addon_utils.renderCourtesyOverlayJpeg)(input.cachedJpeg, {
7843
+ deviceName: input.deviceName,
7844
+ reason: "waking"
7845
+ }),
7846
+ source: "cached-snapshot"
7847
+ };
7848
+ } catch (err) {
7849
+ input.logger?.warn("prestream: cached frame could not be treated — using the card", {
7850
+ ...tags,
7851
+ meta: { error: require_dist.errMsg(err) }
7852
+ });
7853
+ }
7854
+ return {
7855
+ jpeg: await (0, _camstack_system_addon_utils.renderCourtesyJpeg)({
7856
+ deviceName: input.deviceName,
7857
+ reason: "waking",
7858
+ width: input.width,
7859
+ height: input.height
7860
+ }),
7861
+ source: "generated-card"
7862
+ };
7863
+ }
7864
+ function planPrestream(input) {
7865
+ if (input.sourceUrl === void 0 || !input.sourceUrl.startsWith("lazy:rfc4571:")) return { refusal: "not-lazy" };
7866
+ if (input.width === void 0 || input.height === void 0 || input.width <= 0 || input.height <= 0) return { refusal: "unknown-geometry" };
7867
+ const codec = normaliseCodec$1(input.codec);
7868
+ if (codec === null) return { refusal: "unknown-codec" };
7869
+ return { plan: {
7870
+ width: input.width,
7871
+ height: input.height,
7872
+ codec
7873
+ } };
7874
+ }
7875
+ /**
7876
+ * `null` for a codec nobody has read. NOT a default of h264: a prestream in the
7877
+ * wrong codec is a stream the consumer cannot decode at all, which is worse
7878
+ * than the black screen it replaces.
7879
+ */
7880
+ function normaliseCodec$1(codec) {
7881
+ if (codec === void 0) return null;
7882
+ const c = codec.toLowerCase().replace(/[.\s-]/g, "");
7883
+ if (c === "h264" || c === "avc" || c === "avc1") return "h264";
7884
+ if (c === "h265" || c === "hevc" || c === "hvc1" || c === "hev1") return "h265";
7885
+ return null;
7886
+ }
7887
+ //#endregion
7334
7888
  //#region src/stream-broker/stream-broker/profile-restreamers.ts
7335
7889
  /**
7336
7890
  * Diff the desired profile→source aliasing against the current
@@ -11464,6 +12018,124 @@ function getPlaceholderFrame(kind, codec) {
11464
12018
  return buf;
11465
12019
  }
11466
12020
  //#endregion
12021
+ //#region src/stream-broker/stream-broker/prestream-pump.ts
12022
+ /**
12023
+ * The prestream pump: ONE encode, then a 1 fps re-emit until real media lands.
12024
+ *
12025
+ * ── What it is for ────────────────────────────────────────────────────────
12026
+ * A lazy source is BUILT on demand, and on this hub that build costs 2.2 to
12027
+ * 12.4 s. For all of it the consumer had black — and Amazon allows SIX SECONDS
12028
+ * for an Alexa SDP answer, so a cold battery camera could not answer an Echo at
12029
+ * all. The pump makes the broker servable before the camera is: the camera's
12030
+ * own last picture under a "Waking" treatment, at the camera's real geometry,
12031
+ * replaced by real media the instant the first real packet arrives.
12032
+ *
12033
+ * ── The three rules it exists to hold ─────────────────────────────────────
12034
+ * 1. **Prepare ONCE.** The picture is fetched and encoded a single time per
12035
+ * wake and the bytes are cached here. Re-fetching per tick would put a
12036
+ * snapshot round-trip and a fork+exec on every second of every waking
12037
+ * camera, and would move frames across a process boundary at frame-rate —
12038
+ * D9/D18.
12039
+ * 2. **A late preparation lands nowhere.** The encode is async. If the camera
12040
+ * woke while it was running the pump has already been stopped, and starting
12041
+ * the timer then would paint a still picture over live media.
12042
+ * 3. **It never dials.** The pump has no source, no socket and no demand. It
12043
+ * is started alongside the publisher request, never instead of it.
12044
+ *
12045
+ * Emission is the caller's: {@link PrestreamPumpOptions.emit} is where the
12046
+ * `isPlaceholder: true` packet is built, because that flag belongs to the
12047
+ * broker's packet contract and the containment that reads it lives there.
12048
+ */
12049
+ var DEFAULT_PRESTREAM_INTERVAL_MS = 1e3;
12050
+ var PrestreamPump = class {
12051
+ options;
12052
+ intervalMs;
12053
+ tags;
12054
+ timer;
12055
+ /** True from `start()` until `stop()`, INCLUDING while the encode runs. */
12056
+ active = false;
12057
+ frame = null;
12058
+ startedAt = 0;
12059
+ emitted = 0;
12060
+ constructor(options) {
12061
+ this.options = options;
12062
+ this.intervalMs = options.intervalMs ?? DEFAULT_PRESTREAM_INTERVAL_MS;
12063
+ this.tags = options.deviceId === void 0 ? {} : { tags: { deviceId: options.deviceId } };
12064
+ }
12065
+ /** Whether the window is currently being filled (or about to be). */
12066
+ get running() {
12067
+ return this.active;
12068
+ }
12069
+ /**
12070
+ * Open the window. Idempotent: a second call while the first preparation is
12071
+ * still in flight is a no-op, so a reconnect tick that re-enters the lazy
12072
+ * branch every second does not spawn an encode every second.
12073
+ */
12074
+ start(sourceUrl) {
12075
+ if (this.active) return;
12076
+ this.active = true;
12077
+ this.startedAt = Date.now();
12078
+ this.emitted = 0;
12079
+ this.options.prepare({ sourceUrl }).then((frame) => {
12080
+ if (!this.active) {
12081
+ this.options.logger?.debug("prestream: preparation landed after the window closed", { ...this.tags });
12082
+ return;
12083
+ }
12084
+ if (frame === null) {
12085
+ this.options.logger?.info("prestream: refused — the window goes unfilled", { ...this.tags });
12086
+ this.active = false;
12087
+ return;
12088
+ }
12089
+ this.frame = frame;
12090
+ this.options.logger?.info("prestream: filling the wake window", {
12091
+ ...this.tags,
12092
+ meta: {
12093
+ codec: frame.codec,
12094
+ bytes: frame.annexB.length,
12095
+ intervalMs: this.intervalMs,
12096
+ preparedInMs: Date.now() - this.startedAt
12097
+ }
12098
+ });
12099
+ this.tick();
12100
+ this.timer = setInterval(() => this.tick(), this.intervalMs);
12101
+ }).catch((err) => {
12102
+ this.options.logger?.warn("prestream: preparation failed — the window goes unfilled", {
12103
+ ...this.tags,
12104
+ meta: { error: require_dist.errMsg(err) }
12105
+ });
12106
+ this.active = false;
12107
+ });
12108
+ }
12109
+ /**
12110
+ * Close the window. Called on the first NON-placeholder packet — the one edge
12111
+ * the prestream is defined by — and on the broker's own teardown paths so a
12112
+ * stopped broker leaves no timer behind.
12113
+ */
12114
+ stop(reason) {
12115
+ if (!this.active) return;
12116
+ this.active = false;
12117
+ if (this.timer) {
12118
+ clearInterval(this.timer);
12119
+ this.timer = void 0;
12120
+ }
12121
+ this.options.logger?.info("prestream: window closed", {
12122
+ ...this.tags,
12123
+ meta: {
12124
+ reason,
12125
+ framesEmitted: this.emitted,
12126
+ heldMs: Date.now() - this.startedAt
12127
+ }
12128
+ });
12129
+ this.frame = null;
12130
+ }
12131
+ tick() {
12132
+ const frame = this.frame;
12133
+ if (!this.active || frame === null) return;
12134
+ this.emitted++;
12135
+ this.options.emit(frame);
12136
+ }
12137
+ };
12138
+ //#endregion
11467
12139
  //#region src/stream-broker/stream-broker/stream-pipe-server.ts
11468
12140
  /**
11469
12141
  * A lightweight TCP server that broadcasts raw encoded video bytes
@@ -11756,6 +12428,23 @@ var StreamBroker = class StreamBroker {
11756
12428
  audioMutedDropLogged = false;
11757
12429
  reconnectTimer;
11758
12430
  placeholderTimer;
12431
+ /**
12432
+ * The prestream pump, armed by the manager via `setPrestreamProvider`.
12433
+ * `undefined` on a broker whose manager supplied none — the pre-prestream
12434
+ * behaviour, unchanged.
12435
+ */
12436
+ prestreamPump;
12437
+ /**
12438
+ * Latched once real media has been seen on this session.
12439
+ *
12440
+ * The lazy branch is re-entered on every materialize poll tick, so stopping
12441
+ * the pump is not enough on its own: the very next tick would re-open a
12442
+ * window that real bytes have already closed, and the consumer would get a
12443
+ * still picture painted over live video every second. Reset exactly where
12444
+ * `firstKeyframeReceived` is — broker stop and suspend — so a battery camera
12445
+ * that goes back to sleep gets its prestream again on the next wake.
12446
+ */
12447
+ prestreamWindowClosed = false;
11759
12448
  reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
11760
12449
  /**
11761
12450
  * Dial rate-limiter (2026-07-23 storm fix): the earliest wall-clock at
@@ -11850,6 +12539,15 @@ var StreamBroker = class StreamBroker {
11850
12539
  */
11851
12540
  materializeRequestedAt = 0;
11852
12541
  /**
12542
+ * The hold a camera's own signal bought. See `wake-hold.ts` — a term of
12543
+ * {@link hasLiveIntent}, so the predicate's owner owns the state: putting it
12544
+ * in the manager would give two places a vote on whether to dial.
12545
+ */
12546
+ wakeHold = NO_HOLD;
12547
+ /** Fires when the hold lapses, so the suspend happens instead of waiting for
12548
+ * an unrelated edge. Re-armed on every extension. */
12549
+ wakeHoldTimer;
12550
+ /**
11853
12551
  * Rolling source-RTP activity watchdog. Distinct from the first-video
11854
12552
  * watchdog: this one ARMS at DIAL time (so a connection that opens the
11855
12553
  * loopback TCP but never delivers media is still detected) and RESETS on
@@ -12149,6 +12847,18 @@ var StreamBroker = class StreamBroker {
12149
12847
  getLastPacketAt() {
12150
12848
  return this.lastPacketAt;
12151
12849
  }
12850
+ /**
12851
+ * Whether a LAZY publisher is being built right now.
12852
+ *
12853
+ * A consumer deciding whether it is worth waiting for this source needs to
12854
+ * tell "cold and idle" from "waking up": the first cannot be waited for at
12855
+ * all (the dial is the demand that starts the wake), the second can.
12856
+ * `getFirstVideoWatchdogStatus` carries the same number and is a TEST SEAM —
12857
+ * this is the production read.
12858
+ */
12859
+ isMaterializing() {
12860
+ return this.materializeRequestedAt > 0;
12861
+ }
12152
12862
  /** Source type of the active stream (rtsp/push/rtmp/placeholder/error). */
12153
12863
  getActiveSourceType() {
12154
12864
  return this.source?.type ?? "unknown";
@@ -12237,6 +12947,38 @@ var StreamBroker = class StreamBroker {
12237
12947
  this.requestSourceRefresh = notify;
12238
12948
  }
12239
12949
  /**
12950
+ * Arm the PRESTREAM — the fill for the window between "someone asked for this
12951
+ * camera" and "the camera is awake".
12952
+ *
12953
+ * Manager-owned, because everything the fill needs is the manager's: the
12954
+ * camera's probed geometry and codec (`planPrestream`), its last cached
12955
+ * snapshot, its name, and the ffmpeg binary. The broker contributes the one
12956
+ * thing it owns — WHEN the window is open — and nothing else.
12957
+ *
12958
+ * A broker with no provider behaves exactly as it did before the prestream
12959
+ * existed: the lazy branch asks the publisher to materialize and the consumer
12960
+ * sees black, which is the outcome every refusal falls back to.
12961
+ */
12962
+ setPrestreamProvider(provider) {
12963
+ this.prestreamPump = new PrestreamPump({
12964
+ prepare: provider.prepare,
12965
+ emit: (frame) => {
12966
+ const now = Date.now();
12967
+ this.pushEncodedPacket({
12968
+ type: "video",
12969
+ data: frame.annexB,
12970
+ pts: now,
12971
+ dts: now,
12972
+ keyframe: true,
12973
+ codec: frame.codec,
12974
+ isPlaceholder: true
12975
+ });
12976
+ },
12977
+ ...this.logger ? { logger: this.logger } : {},
12978
+ ...this.numericDeviceId === void 0 ? {} : { deviceId: this.numericDeviceId }
12979
+ });
12980
+ }
12981
+ /**
12240
12982
  * Fire `requestSourceRefresh` at most once per ~500 ms. The
12241
12983
  * dial-failure path fans into onError + reader.connect().catch; we
12242
12984
  * only want one round-trip to the publisher per real failure.
@@ -12286,7 +13028,7 @@ var StreamBroker = class StreamBroker {
12286
13028
  */
12287
13029
  redial(reason) {
12288
13030
  if (this.manualStop || this.stopping || !this.source) return;
12289
- if (!this.hasDemand()) return;
13031
+ if (!this.hasLiveIntent()) return;
12290
13032
  this.logger?.info("source redial — the camera changed under a live reader", { meta: {
12291
13033
  reason,
12292
13034
  status: this._status
@@ -12302,7 +13044,7 @@ var StreamBroker = class StreamBroker {
12302
13044
  }
12303
13045
  kickReconnect() {
12304
13046
  if (this.manualStop || this.stopping || !this.source) return;
12305
- if (!this.hasDemand()) return;
13047
+ if (!this.hasLiveIntent()) return;
12306
13048
  if (!this.reconnectTimer) return;
12307
13049
  const nowMs = Date.now();
12308
13050
  if (nowMs - this.lastKickAt < INITIAL_RECONNECT_DELAY_MS) return;
@@ -12344,7 +13086,7 @@ var StreamBroker = class StreamBroker {
12344
13086
  } });
12345
13087
  await this.pipeServer.start();
12346
13088
  this.logger?.debug("Pipe server started", { meta: { url: this.pipeServer.getUrl() } });
12347
- if (isLazyDialSource(source.type) && !this.hasDemand()) {
13089
+ if (isLazyDialSource(source.type) && !this.hasLiveIntent()) {
12348
13090
  this._suspended = true;
12349
13091
  this._status = "idle";
12350
13092
  this.logger?.info("Broker registered idle — no demand, source dial deferred");
@@ -12402,6 +13144,8 @@ var StreamBroker = class StreamBroker {
12402
13144
  clearInterval(this.placeholderTimer);
12403
13145
  this.placeholderTimer = void 0;
12404
13146
  }
13147
+ this.prestreamPump?.stop("broker stopped");
13148
+ this.prestreamWindowClosed = false;
12405
13149
  if (this.reconnectTimer) {
12406
13150
  clearTimeout(this.reconnectTimer);
12407
13151
  this.reconnectTimer = void 0;
@@ -12563,14 +13307,98 @@ var StreamBroker = class StreamBroker {
12563
13307
  this.checkDemand();
12564
13308
  }
12565
13309
  /** True when at least one consumer needs the stream alive. */
12566
- hasDemand() {
13310
+ /**
13311
+ * A signal the CAMERA reported about itself.
13312
+ *
13313
+ * Two terms must hold before anything is dialled, and they are the whole
13314
+ * safety of this feature:
13315
+ *
13316
+ * - the caller has already checked the signal is ENABLED for this camera
13317
+ * (the broker manager owns that setting), and
13318
+ * - somebody is actually waiting on the prestream. Waking a camera nobody is
13319
+ * watching is the one thing a battery camera must never do, and a signal
13320
+ * arriving with no consumer is exactly that.
13321
+ *
13322
+ * Returns what it did, so the caller's log line can say it. A hold that is
13323
+ * merely EXTENDED does not re-dial: the camera is already up.
13324
+ */
13325
+ reportWakeSignal(code, holdMs, at) {
13326
+ if (this.rtspRestreamer.getPrestreamSessionCount() === 0) return "no-consumer";
13327
+ const wasLive = holdIsLive(this.wakeHold, at);
13328
+ const { hold, outcome } = absorbSignal({
13329
+ current: this.wakeHold,
13330
+ now: at,
13331
+ holdMs
13332
+ });
13333
+ this.wakeHold = hold;
13334
+ this.logger?.info("wake signal from the camera", {
13335
+ tags: { deviceId: this.numericDeviceId },
13336
+ meta: {
13337
+ brokerId: this.deviceId,
13338
+ code,
13339
+ outcome,
13340
+ signals: hold.signals,
13341
+ untilMs: Math.max(0, hold.until - at),
13342
+ runMs: hold.runStartedAt > 0 ? at - hold.runStartedAt : 0
13343
+ }
13344
+ });
13345
+ this.armWakeHoldTimer(at);
13346
+ if (outcome === "quarantined") return "no-consumer";
13347
+ if (wasLive) return "hold-extended";
13348
+ this.checkDemand();
13349
+ return "woke";
13350
+ }
13351
+ /**
13352
+ * Look at the hold when it lapses, so the camera actually goes back to sleep
13353
+ * instead of waiting for an unrelated edge to notice.
13354
+ */
13355
+ armWakeHoldTimer(now) {
13356
+ if (this.wakeHoldTimer) clearTimeout(this.wakeHoldTimer);
13357
+ const delay = this.wakeHold.until - now;
13358
+ if (delay <= 0) return;
13359
+ this.wakeHoldTimer = setTimeout(() => {
13360
+ this.wakeHoldTimer = void 0;
13361
+ this.logger?.info("wake hold lapsed — back to the prestream picture", {
13362
+ tags: { deviceId: this.numericDeviceId },
13363
+ meta: {
13364
+ brokerId: this.deviceId,
13365
+ signals: this.wakeHold.signals,
13366
+ runMs: this.wakeHold.runStartedAt > 0 ? Date.now() - this.wakeHold.runStartedAt : 0
13367
+ }
13368
+ });
13369
+ this.checkDemand();
13370
+ }, delay);
13371
+ this.wakeHoldTimer.unref?.();
13372
+ }
13373
+ /**
13374
+ * Anything that must be FED, prestream sessions included.
13375
+ *
13376
+ * The only consumer of this is the prestream pump: it says "somebody is
13377
+ * watching, keep the picture going", and says nothing about whether the
13378
+ * camera may be dialled. {@link hasLiveIntent} answers that, and it is the
13379
+ * one every dial path asks.
13380
+ */
13381
+ hasAnyConsumer() {
13382
+ if (this.hasLiveIntent()) return true;
13383
+ return this.rtspRestreamer.getPrestreamSessionCount() > 0;
13384
+ }
13385
+ /**
13386
+ * Consumers that may DIAL. Every term of the former `hasDemand`, with the
13387
+ * RTSP session count filtered to exclude prestream dials, plus the wake hold.
13388
+ *
13389
+ * For a broker with no prestream session attached this is term-for-term what
13390
+ * `hasDemand` was, which is why every existing camera, consumer and test
13391
+ * behaves identically.
13392
+ */
13393
+ hasLiveIntent() {
12567
13394
  if (this.encodedCallbacks.size > 0) return true;
12568
13395
  if (this.rtpVideoCallbacks.size > 0) return true;
12569
13396
  if ((this.frameHandlePlane?.subscriberCount ?? 0) > 0) return true;
12570
13397
  if (this.audioChunkPlane.subscriberCount > 0) return true;
12571
- if (this.rtspRestreamer.getSessionCount() > 0) return true;
13398
+ if (this.rtspRestreamer.getLiveIntentSessionCount() > 0) return true;
12572
13399
  if (this.pipeServer.getClientCount() > 0) return true;
12573
13400
  if (this.prebufferHold !== null) return true;
13401
+ if (holdIsLive(this.wakeHold, Date.now())) return true;
12574
13402
  return false;
12575
13403
  }
12576
13404
  /**
@@ -12686,7 +13514,7 @@ var StreamBroker = class StreamBroker {
12686
13514
  checkDemand() {
12687
13515
  if (this.manualStop || this.stopping) return;
12688
13516
  this.reconcileAudioConsumer();
12689
- if (this.hasDemand()) {
13517
+ if (this.hasLiveIntent()) {
12690
13518
  if (!this.demandActive) {
12691
13519
  this.demandActive = true;
12692
13520
  this.demandObserver?.(true);
@@ -12710,9 +13538,19 @@ var StreamBroker = class StreamBroker {
12710
13538
  } else if (!this._suspended) {
12711
13539
  if (!this.suspendTimer) this.suspendTimer = setTimeout(() => {
12712
13540
  this.suspendTimer = void 0;
12713
- if (!this.hasDemand() && !this.manualStop && !this.stopping) this.suspend();
13541
+ if (!this.hasLiveIntent() && !this.manualStop && !this.stopping) this.suspend();
12714
13542
  }, SUSPEND_GRACE_MS);
12715
13543
  }
13544
+ this.prestreamWanted = this.hasAnyConsumer() && !this.hasLiveIntent();
13545
+ }
13546
+ /**
13547
+ * Whether a prestream picture should be flowing right now: somebody is
13548
+ * watching and the camera is not up. Read by the pump.
13549
+ */
13550
+ prestreamWanted = false;
13551
+ /** Whether the picture should be flowing — the pump's one question. */
13552
+ wantsPrestream() {
13553
+ return this.prestreamWanted;
12716
13554
  }
12717
13555
  /** Suspend the stream: kill ffmpeg/native client, clear buffers, go idle. Source is preserved for resume. */
12718
13556
  suspend() {
@@ -12737,6 +13575,8 @@ var StreamBroker = class StreamBroker {
12737
13575
  clearInterval(this.placeholderTimer);
12738
13576
  this.placeholderTimer = void 0;
12739
13577
  }
13578
+ this.prestreamPump?.stop("broker suspended");
13579
+ this.prestreamWindowClosed = false;
12740
13580
  this.preBuffer.clear();
12741
13581
  this.decoderSeedBuffer.clear();
12742
13582
  this.clipBuffer?.clear();
@@ -12820,6 +13660,10 @@ var StreamBroker = class StreamBroker {
12820
13660
  }
12821
13661
  pushEncodedPacket(packet) {
12822
13662
  if (this.stopping) return;
13663
+ if (packet.isPlaceholder !== true && !this.prestreamWindowClosed) {
13664
+ this.prestreamWindowClosed = true;
13665
+ this.prestreamPump?.stop("first real packet");
13666
+ }
12823
13667
  if (this.source?.type === "push") {
12824
13668
  this.stopErrorPlaceholder();
12825
13669
  if (this._status === "error") this._status = "streaming";
@@ -12861,17 +13705,17 @@ var StreamBroker = class StreamBroker {
12861
13705
  }
12862
13706
  }
12863
13707
  if (packet.type === "video" && !this.isRtpSource()) this.preBuffer.push(packet);
12864
- if (packet.type === "video" && this.clipBuffer) this.clipBuffer.push(packet);
13708
+ if (packet.type === "video" && this.clipBuffer && packet.isPlaceholder !== true) this.clipBuffer.push(packet);
12865
13709
  this.fanOutEncoded(packet);
12866
13710
  if (packet.type === "video") {
12867
13711
  this.pipeServer.broadcast(packet.data);
12868
13712
  if (!this.rtspRestreamer.rtpPassthroughMode) this.rtspRestreamer.pushPacket(packet);
12869
- if (!this.firstKeyframeReceived && packet.keyframe) {
13713
+ if (!this.firstKeyframeReceived && packet.keyframe && packet.isPlaceholder !== true) {
12870
13714
  this.firstKeyframeReceived = true;
12871
13715
  this.logger?.info("First keyframe received — decoder can start");
12872
13716
  }
12873
13717
  }
12874
- if (packet.type === "video" && this.firstKeyframeReceived) {
13718
+ if (packet.type === "video" && this.firstKeyframeReceived && packet.isPlaceholder !== true) {
12875
13719
  const plane = this.frameHandlePlane;
12876
13720
  if (plane) {
12877
13721
  const decoderPacket = this.decoderPacketWithParamSets(packet);
@@ -14435,7 +15279,7 @@ var StreamBroker = class StreamBroker {
14435
15279
  }
14436
15280
  scheduleReconnect() {
14437
15281
  if (this.manualStop || !this.source) return;
14438
- if (!this.hasDemand()) {
15282
+ if (!this.hasLiveIntent()) {
14439
15283
  this._suspended = true;
14440
15284
  this._status = "idle";
14441
15285
  this.stopErrorPlaceholder();
@@ -14547,6 +15391,7 @@ var StreamBroker = class StreamBroker {
14547
15391
  });
14548
15392
  this._status = "error";
14549
15393
  this.notifySourceRefresh();
15394
+ if (!this.prestreamWindowClosed) this.prestreamPump?.start(source.url);
14550
15395
  this.scheduleReconnect();
14551
15396
  return;
14552
15397
  }
@@ -15276,7 +16121,23 @@ var DeviceOverrideSchema = require_dist.object({
15276
16121
  derivedStreams: require_dist.record(require_dist.string(), DerivedStreamSchema).optional(),
15277
16122
  hwaccel: require_dist.record(require_dist.string(), FfmpegHwAccelSettingSchema).optional(),
15278
16123
  sourceParser: require_dist.record(require_dist.string(), SourceParserSettingSchema).optional(),
15279
- sourceParserArgs: require_dist.record(require_dist.string(), SourceParserArgsEntrySchema).optional()
16124
+ sourceParserArgs: require_dist.record(require_dist.string(), SourceParserArgsEntrySchema).optional(),
16125
+ /**
16126
+ * The on-demand PRESTREAM, per camera.
16127
+ *
16128
+ * Absent means the operator has never touched it, which is NOT the same as
16129
+ * off: `enabledSignals` absent means "every signal this camera recommends",
16130
+ * so a Reolink works the moment a prestream URL is dialled without an
16131
+ * operator hunting for a checkbox. An empty ARRAY is a deliberate "none" and
16132
+ * is honoured — the two are different answers and the shape keeps them
16133
+ * apart.
16134
+ */
16135
+ prestream: require_dist.object({
16136
+ /** Signal codes allowed to wake this camera. Absent ⇒ the recommended set. */
16137
+ enabledSignals: require_dist.array(require_dist.string()).optional(),
16138
+ /** How long the camera stays up after the LAST signal. Absent ⇒ default. */
16139
+ holdMs: require_dist.number().int().min(1e3).max(3e5).optional()
16140
+ }).optional()
15280
16141
  });
15281
16142
  /**
15282
16143
  * Read `audioEnabled` off a `privacyMask.getStatus` payload (D83 reconcile).
@@ -15420,6 +16281,12 @@ var StreamBrokerManager = class StreamBrokerManager {
15420
16281
  profileRestreamerBindings = /* @__PURE__ */ new Map();
15421
16282
  /** Per-device published cam-stream registry. */
15422
16283
  cameraStreams = /* @__PURE__ */ new Map();
16284
+ /**
16285
+ * The encoded prestream frame per broker, with the digest of the picture it
16286
+ * was made from. One entry is one still image — kilobytes — and it is
16287
+ * dropped with the broker in `releaseBroker`.
16288
+ */
16289
+ prestreamFrames = /* @__PURE__ */ new Map();
15423
16290
  /** Monotonic insertion counter per device — feeds `CameraStreamInternal.order`. */
15424
16291
  streamOrderSeq = /* @__PURE__ */ new Map();
15425
16292
  /** Persisted operator intent: which cam stream serves which profile. */
@@ -15771,7 +16638,10 @@ var StreamBrokerManager = class StreamBrokerManager {
15771
16638
  sourceHasMedia: (brokerId) => {
15772
16639
  const broker = this.brokers.get(brokerId);
15773
16640
  if (broker === void 0) return null;
15774
- return broker.getLastPacketAt() > 0;
16641
+ return sourceWaitState({
16642
+ hasMedia: broker.getLastPacketAt() > 0,
16643
+ materializing: broker.isMaterializing()
16644
+ });
15775
16645
  },
15776
16646
  ffmpegConfig: () => this.ffmpegConfig,
15777
16647
  ...this.costRegistry === null ? {} : { costRegistry: this.costRegistry },
@@ -16307,6 +17177,150 @@ var StreamBrokerManager = class StreamBrokerManager {
16307
17177
  * firehose so we don't filter the entire device-state stream
16308
17178
  * client-side and we get a typed payload.
16309
17179
  */
17180
+ /**
17181
+ * Build the per-broker PRESTREAM provider — the fill for the window between
17182
+ * "someone asked for this camera" and "the camera is awake".
17183
+ *
17184
+ * On this hub that window is 2.2-12.4 s for a lazy source, and it is not
17185
+ * merely ugly: Amazon allows SIX SECONDS for an Alexa SDP answer, so a cold
17186
+ * battery camera could not answer an Echo at all.
17187
+ *
17188
+ * Four facts are needed and all four are the manager's, which is why the
17189
+ * provider is a closure here rather than anything the broker knows about:
17190
+ * the probed geometry and codec ({@link planPrestream}), the camera's last
17191
+ * cached snapshot, its name, and the ffmpeg binary path.
17192
+ *
17193
+ * `null` is a REFUSAL, named in the log. Refusing is cheap — the consumer
17194
+ * gets the black screen it got before the prestream existed — and the
17195
+ * alternative for an unknown geometry is the decoder freeze the whole
17196
+ * synthetic-frame path is shaped around.
17197
+ */
17198
+ buildPrestreamProvider(deviceId, camStreamId, brokerId, logger) {
17199
+ return { prepare: async ({ sourceUrl }) => {
17200
+ const gate = prestreamEligible({
17201
+ batteryOperated: await this.isBatteryOperated(deviceId),
17202
+ declaresSignals: (await this.declaredSignalCodes(deviceId)).length > 0
17203
+ });
17204
+ if (!gate.eligible) {
17205
+ logger.info("prestream: not offered for this camera", {
17206
+ tags: { deviceId },
17207
+ meta: {
17208
+ brokerId,
17209
+ reason: gate.reason
17210
+ }
17211
+ });
17212
+ return null;
17213
+ }
17214
+ const stream = this.cameraStreams.get(deviceId)?.get(camStreamId);
17215
+ const codec = this.lastProbedByBroker.get(brokerId)?.codec ?? stream?.codec;
17216
+ const planned = planPrestream({
17217
+ sourceUrl,
17218
+ width: stream?.resolution?.width,
17219
+ height: stream?.resolution?.height,
17220
+ codec
17221
+ });
17222
+ if ("refusal" in planned) {
17223
+ logger.info("prestream: no fill for this camera", {
17224
+ tags: { deviceId },
17225
+ meta: {
17226
+ refusal: planned.refusal,
17227
+ camStreamId,
17228
+ width: stream?.resolution?.width ?? null,
17229
+ height: stream?.resolution?.height ?? null,
17230
+ codec: codec ?? null
17231
+ }
17232
+ });
17233
+ return null;
17234
+ }
17235
+ return await this.preparePrestreamFrame(deviceId, brokerId, planned.plan, logger);
17236
+ } };
17237
+ }
17238
+ /**
17239
+ * Fetch the picture, compose the treatment, encode ONCE and cache the bytes.
17240
+ *
17241
+ * The cache is keyed on the picture's own digest plus the geometry, so a
17242
+ * second wake of the same camera re-uses the frame and spawns nothing: the
17243
+ * picture changes at most hourly, and a fork+exec per wake per sleeping
17244
+ * camera is the cost this shape exists to avoid.
17245
+ */
17246
+ async preparePrestreamFrame(deviceId, brokerId, plan, logger) {
17247
+ if (!this.api) {
17248
+ logger.info("prestream: no api access — the window goes unfilled", { tags: { deviceId } });
17249
+ return null;
17250
+ }
17251
+ const api = this.api;
17252
+ let cachedJpeg = null;
17253
+ try {
17254
+ const image = await api.snapshot.getSnapshot.query({ deviceId });
17255
+ if (image) cachedJpeg = Buffer.from(image.base64, "base64");
17256
+ } catch (err) {
17257
+ logger.debug("prestream: cached snapshot read failed — falling back to the card", {
17258
+ tags: { deviceId },
17259
+ meta: { error: require_dist.errMsg(err) }
17260
+ });
17261
+ }
17262
+ let deviceName = `#${String(deviceId)}`;
17263
+ try {
17264
+ const device = await api.deviceManager.getDevice.query({ deviceId });
17265
+ if (device?.name) deviceName = device.name;
17266
+ } catch (err) {
17267
+ logger.debug("prestream: device name read failed — using the numeric id", {
17268
+ tags: { deviceId },
17269
+ meta: { error: require_dist.errMsg(err) }
17270
+ });
17271
+ }
17272
+ const key = `${(0, node_crypto.createHash)("sha1").update(cachedJpeg ?? Buffer.alloc(0)).update(deviceName).digest("hex")}:${String(plan.width)}x${String(plan.height)}:${plan.codec}`;
17273
+ const cached = this.prestreamFrames.get(brokerId);
17274
+ if (cached && cached.key === key) {
17275
+ logger.debug("prestream: re-using the encoded frame", {
17276
+ tags: { deviceId },
17277
+ meta: { bytes: cached.frame.annexB.length }
17278
+ });
17279
+ return cached.frame;
17280
+ }
17281
+ const picture = await composePrestreamPicture({
17282
+ cachedJpeg,
17283
+ deviceName,
17284
+ width: plan.width,
17285
+ height: plan.height,
17286
+ logger,
17287
+ deviceId
17288
+ });
17289
+ logger.info("prestream: picture composed", {
17290
+ tags: { deviceId },
17291
+ meta: {
17292
+ source: picture.source,
17293
+ width: plan.width,
17294
+ height: plan.height,
17295
+ codec: plan.codec
17296
+ }
17297
+ });
17298
+ let annexB;
17299
+ try {
17300
+ annexB = await encodePrestreamIdr({
17301
+ ffmpegPath: this.ffmpegConfig.binaryPath,
17302
+ plan,
17303
+ jpeg: picture.jpeg,
17304
+ logger,
17305
+ deviceId
17306
+ });
17307
+ } catch (err) {
17308
+ logger.warn("prestream: encode failed — the window goes unfilled", {
17309
+ tags: { deviceId },
17310
+ meta: { error: require_dist.errMsg(err) }
17311
+ });
17312
+ return null;
17313
+ }
17314
+ const frame = {
17315
+ annexB,
17316
+ codec: plan.codec
17317
+ };
17318
+ this.prestreamFrames.set(brokerId, {
17319
+ key,
17320
+ frame
17321
+ });
17322
+ return frame;
17323
+ }
16310
17324
  subscribePlaceholderStateSources(bus) {
16311
17325
  bus.subscribe({ category: require_dist.EventCategory.BatteryOnStatusChanged }, (event) => {
16312
17326
  const data = event.data;
@@ -16461,11 +17475,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16461
17475
  if (!api) return;
16462
17476
  await this.batteryWakeOnPlay.run(deviceId, {
16463
17477
  logger: this.logger,
16464
- isBatteryDevice: async (id) => {
16465
- const dev = await api.deviceManager.getDevice.query({ deviceId: id }).catch(() => null);
16466
- if (!dev) return null;
16467
- return dev.features.includes(require_dist.DeviceFeature.BatteryOperated);
16468
- },
17478
+ isBatteryDevice: (id) => this.isBatteryOperated(id),
16469
17479
  readBatteryFacts: async (id) => {
16470
17480
  const slice = await api.deviceState.getCapSlice.query({
16471
17481
  deviceId: id,
@@ -17998,6 +19008,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17998
19008
  const parsed = this.parseBrokerId(brokerId);
17999
19009
  if (!parsed) continue;
18000
19010
  const { deviceId, camStreamId } = parsed;
19011
+ if (!healthEventIsOperatorFacing(camStreamId)) continue;
18001
19012
  const lastPacketAt = broker.getLastPacketAt();
18002
19013
  const wasHealthy = this.streamHealthByBroker.get(brokerId);
18003
19014
  const isHealthy = lastPacketAt > 0 && now - lastPacketAt <= STREAM_STALE_TIMEOUT_MS;
@@ -18288,6 +19299,113 @@ var StreamBrokerManager = class StreamBrokerManager {
18288
19299
  * falls back to the global `ffmpeg.hwAccel`). Read by the transcode pipeline
18289
19300
  * per spawn; affects ONLY the transcode paths.
18290
19301
  */
19302
+ /**
19303
+ * A device reporting one of its own signals — the cap method the provider
19304
+ * calls. See `streamBroker.reportStreamSignal` for the contract.
19305
+ *
19306
+ * Everything that could refuse says WHY, because this is the path that
19307
+ * decides whether a battery camera wakes, and a silent refusal here is
19308
+ * indistinguishable from a signal that never arrived.
19309
+ */
19310
+ async reportStreamSignal(input) {
19311
+ const at = input.at ?? Date.now();
19312
+ const declared = await this.declaredSignalCodes(input.deviceId);
19313
+ if (!declared.includes(input.code)) {
19314
+ this.logger.warn("wake signal REFUSED — this camera never declared that code", {
19315
+ tags: { deviceId: input.deviceId },
19316
+ meta: {
19317
+ code: input.code,
19318
+ declared
19319
+ }
19320
+ });
19321
+ return {
19322
+ accepted: false,
19323
+ reason: "unknown-code"
19324
+ };
19325
+ }
19326
+ const settings = this.deviceOverrides.get(input.deviceId)?.prestream;
19327
+ if (!(settings?.enabledSignals ?? declared).includes(input.code)) return {
19328
+ accepted: false,
19329
+ reason: "not-enabled"
19330
+ };
19331
+ const holdMs = settings?.holdMs ?? 15e3;
19332
+ let outcome = "no-consumer";
19333
+ for (const [brokerId, broker] of this.brokers) {
19334
+ if (this.parseBrokerId(brokerId)?.deviceId !== input.deviceId) continue;
19335
+ const result = broker.reportWakeSignal(input.code, holdMs, at);
19336
+ if (result === "woke") outcome = "woke";
19337
+ else if (result === "hold-extended" && outcome !== "woke") outcome = "hold-extended";
19338
+ }
19339
+ return {
19340
+ accepted: outcome !== "no-consumer",
19341
+ reason: outcome
19342
+ };
19343
+ }
19344
+ /**
19345
+ * What this camera says it can emit. `[]` when it declares the cap and lists
19346
+ * nothing, and `[]` when it does not declare the cap at all — both mean no
19347
+ * signal of that name is acceptable, which is the same refusal.
19348
+ */
19349
+ /**
19350
+ * The signals a camera declares, with their labels — what the form renders.
19351
+ * `[]` on any failure, which the caller reads as "not eligible": a form built
19352
+ * from a list we could not read would offer wakes nobody confirmed.
19353
+ */
19354
+ async declaredSignalList(deviceId) {
19355
+ const read = this.deviceSignalDetailReader;
19356
+ if (read === null) return [];
19357
+ try {
19358
+ return await read(deviceId);
19359
+ } catch {
19360
+ return [];
19361
+ }
19362
+ }
19363
+ async declaredSignalCodes(deviceId) {
19364
+ const read = this.deviceSignalReader;
19365
+ if (read === null) return [];
19366
+ try {
19367
+ return await read(deviceId);
19368
+ } catch {
19369
+ return [];
19370
+ }
19371
+ }
19372
+ /**
19373
+ * How the manager asks a DEVICE what signals it declares. Injected by the
19374
+ * host addon, which owns the device proxy. `null` until it is.
19375
+ */
19376
+ deviceSignalReader = null;
19377
+ setDeviceSignalReader(read) {
19378
+ this.deviceSignalReader = read;
19379
+ }
19380
+ /** The same read, with labels — the form needs them, the wake decision does
19381
+ * not. Injected by the host addon for the same reason. */
19382
+ deviceSignalDetailReader = null;
19383
+ setDeviceSignalDetailReader(read) {
19384
+ this.deviceSignalDetailReader = read;
19385
+ }
19386
+ /**
19387
+ * Whether this device runs on a battery.
19388
+ *
19389
+ * The DEVICE REGISTRY, never the published cam-streams: a sleeping camera has
19390
+ * none — which is precisely when this question gets asked — and
19391
+ * `deviceHasFeature` reads the stream map, so it answers `false` for every
19392
+ * sleeping battery camera. That reasoning was written for the wake-on-play
19393
+ * path and belongs to the question, not to one caller of it.
19394
+ *
19395
+ * `null` when nobody could say, and unknown is refused by every caller rather
19396
+ * than guessed: guessing yes puts a mains camera in a wake-hold loop, and
19397
+ * guessing no silently disables the feature on the cameras it exists for.
19398
+ */
19399
+ async isBatteryOperated(deviceId) {
19400
+ if (!this.api) return null;
19401
+ try {
19402
+ const features = (await this.api.deviceManager.getDevice.query({ deviceId }))?.features;
19403
+ if (!Array.isArray(features)) return null;
19404
+ return features.includes(require_dist.DeviceFeature.BatteryOperated);
19405
+ } catch {
19406
+ return null;
19407
+ }
19408
+ }
18291
19409
  getStreamHwAccelOverride(deviceId, camStreamId) {
18292
19410
  return this.deviceOverrides.get(deviceId)?.hwaccel?.[camStreamId] ?? null;
18293
19411
  }
@@ -18386,6 +19504,12 @@ var StreamBrokerManager = class StreamBrokerManager {
18386
19504
  value: setting,
18387
19505
  label: hwAccelOptionLabel(setting)
18388
19506
  }));
19507
+ const declaredSignals = await this.declaredSignalList(deviceId);
19508
+ const prestreamGate = prestreamEligible({
19509
+ batteryOperated: await this.isBatteryOperated(deviceId),
19510
+ declaresSignals: declaredSignals.length > 0
19511
+ });
19512
+ const prestreamOffered = prestreamGate.eligible;
18389
19513
  const streamTabs = streams.filter((s) => !s.camStreamId.startsWith("derived:")).map((stream) => {
18390
19514
  const camStreamId = stream.camStreamId;
18391
19515
  const brokerId = brokerIdFor(deviceId, camStreamId);
@@ -18485,6 +19609,20 @@ var StreamBrokerManager = class StreamBrokerManager {
18485
19609
  tooltip: "Copy muted URL to clipboard"
18486
19610
  }]
18487
19611
  },
19612
+ ...prestreamOffered ? [{
19613
+ type: "text",
19614
+ key: `rtspPrestreamUrl:${camStreamId}`,
19615
+ label: "RTSP URL (prestream)",
19616
+ description: "Video-only. Shows this camera’s last picture while it sleeps and cuts to live video when one of its wake signals fires — without a consumer on this URL ever keeping the camera awake.",
19617
+ readonlyField: true,
19618
+ span: 2,
19619
+ value: rtspEntry?.url ? `${rtspEntry.url}/prestream` : "inactive",
19620
+ actions: [{
19621
+ action: "copy-value",
19622
+ icon: "copy",
19623
+ tooltip: "Copy prestream URL to clipboard"
19624
+ }]
19625
+ }] : [],
18488
19626
  {
18489
19627
  type: "boolean",
18490
19628
  style: "switch",
@@ -18652,7 +19790,7 @@ var StreamBrokerManager = class StreamBrokerManager {
18652
19790
  }
18653
19791
  ]
18654
19792
  }));
18655
- return { sections: [{
19793
+ const streamBrokerSection = {
18656
19794
  id: "stream-broker-streams",
18657
19795
  title: "",
18658
19796
  tab: "stream-broker",
@@ -18669,7 +19807,37 @@ var StreamBrokerManager = class StreamBrokerManager {
18669
19807
  ...derivedTabs
18670
19808
  ]
18671
19809
  }]
18672
- }, {
19810
+ };
19811
+ const prestreamCfg = override?.prestream;
19812
+ const prestreamSections = prestreamGate.eligible ? [{
19813
+ id: "stream-broker-prestream",
19814
+ title: "Wake signals",
19815
+ tab: "stream-broker",
19816
+ location: "top-tab",
19817
+ order: 50,
19818
+ description: "A consumer can dial this camera’s prestream URL (the profile URL with /prestream) and be shown the last picture while the camera sleeps. It is fed without keeping the camera awake: only the signals below wake it, and it goes back to sleep once they stop.",
19819
+ fields: [...declaredSignals.map((signal) => ({
19820
+ type: "boolean",
19821
+ style: "switch",
19822
+ key: `prestreamSignal:${signal.code}`,
19823
+ label: signal.label,
19824
+ description: signal.recommended ? "Recommended by this camera’s driver." : "Offered by this camera’s driver.",
19825
+ default: signal.recommended,
19826
+ value: prestreamCfg?.enabledSignals === void 0 ? true : prestreamCfg.enabledSignals.includes(signal.code)
19827
+ })), {
19828
+ type: "number",
19829
+ key: "prestreamHoldMs",
19830
+ label: "Stay awake after the last signal",
19831
+ description: "How long the camera keeps streaming once its signals stop. Extended by each new signal, never stacked — continuous motion keeps it up and it sleeps this long after the last one. One run is capped at five minutes whatever this says, and a camera whose sensor never stops is left alone for ten minutes after that.",
19832
+ min: 1e3,
19833
+ max: 3e5,
19834
+ step: 1e3,
19835
+ unit: "ms",
19836
+ default: DEFAULT_WAKE_HOLD_MS,
19837
+ value: prestreamCfg?.holdMs ?? 15e3
19838
+ }]
19839
+ }] : [];
19840
+ const diagnosticsSection = {
18673
19841
  id: "stream-broker-diagnostics",
18674
19842
  title: "Diagnostics",
18675
19843
  tab: "stream-broker",
@@ -18692,7 +19860,12 @@ var StreamBrokerManager = class StreamBrokerManager {
18692
19860
  default: false,
18693
19861
  value: override?.webrtcDebug === true
18694
19862
  }]
18695
- }] };
19863
+ };
19864
+ return { sections: [
19865
+ streamBrokerSection,
19866
+ ...prestreamSections,
19867
+ diagnosticsSection
19868
+ ] };
18696
19869
  }
18697
19870
  async getDeviceLiveContribution(_input) {
18698
19871
  return null;
@@ -18804,6 +19977,31 @@ var StreamBrokerManager = class StreamBrokerManager {
18804
19977
  nextOverride.sourceParserArgs = spa;
18805
19978
  continue;
18806
19979
  }
19980
+ if (fieldKey.startsWith("prestreamSignal:")) {
19981
+ overrideDirty = true;
19982
+ const next = nextEnabledSignals({
19983
+ declared: await this.declaredSignalCodes(deviceId),
19984
+ stored: nextOverride.prestream?.enabledSignals,
19985
+ code: fieldKey.slice(16),
19986
+ enabled: value === null || value === void 0 ? true : Boolean(value)
19987
+ });
19988
+ nextOverride.prestream = {
19989
+ ...nextOverride.prestream,
19990
+ enabledSignals: [...next]
19991
+ };
19992
+ continue;
19993
+ }
19994
+ if (fieldKey === "prestreamHoldMs") {
19995
+ overrideDirty = true;
19996
+ const raw = typeof value === "number" ? value : Number(value);
19997
+ if (!Number.isFinite(raw)) continue;
19998
+ const clamped = Math.min(3e5, Math.max(1e3, Math.round(raw)));
19999
+ nextOverride.prestream = {
20000
+ ...nextOverride.prestream,
20001
+ holdMs: clamped
20002
+ };
20003
+ continue;
20004
+ }
18807
20005
  if (fieldKey === "streamingDebug") {
18808
20006
  overrideDirty = true;
18809
20007
  const next = value === null || value === void 0 ? void 0 : Boolean(value);
@@ -19455,6 +20653,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19455
20653
  broker.setRequestSourceRefresh(() => {
19456
20654
  this.emitRequestStreamSourceRefresh(deviceId, camStreamId, brokerId);
19457
20655
  });
20656
+ broker.setPrestreamProvider(this.buildPrestreamProvider(deviceId, camStreamId, brokerId, brokerLogger));
19458
20657
  try {
19459
20658
  await broker.start(source);
19460
20659
  } catch (err) {
@@ -19502,6 +20701,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19502
20701
  this.streamOfflineReasonByBroker.delete(brokerId);
19503
20702
  await broker.stop();
19504
20703
  this.brokers.delete(brokerId);
20704
+ this.prestreamFrames.delete(brokerId);
19505
20705
  this.sweepFrameSubscriptions(brokerId);
19506
20706
  this.rtspProvider.persistTokens();
19507
20707
  this.syncProfileRestreamers(deviceId);
@@ -19968,6 +21168,28 @@ var WEBRTC_FALLBACK_PROFILE = {
19968
21168
  },
19969
21169
  audio: "passthrough"
19970
21170
  };
21171
+ function shouldWriteLossLine(input) {
21172
+ if (input.lastWritten === null) return {
21173
+ write: true,
21174
+ reason: "first"
21175
+ };
21176
+ if (input.sample.hasReceiverReport !== input.lastWritten.hasReceiverReport) return {
21177
+ write: true,
21178
+ reason: "reporting-changed"
21179
+ };
21180
+ if (Math.abs(input.sample.lossPct - input.lastWritten.lossPct) >= 2) return {
21181
+ write: true,
21182
+ reason: "loss-moved"
21183
+ };
21184
+ if (input.ticksSinceWrite >= 10) return {
21185
+ write: true,
21186
+ reason: "heartbeat"
21187
+ };
21188
+ return {
21189
+ write: false,
21190
+ reason: "quiet"
21191
+ };
21192
+ }
19971
21193
  //#endregion
19972
21194
  //#region src/stream-broker/webrtc/mdns-resolve.ts
19973
21195
  /**
@@ -20943,6 +22165,14 @@ function selectAudioClock(mimeType) {
20943
22165
  }
20944
22166
  //#endregion
20945
22167
  //#region src/stream-broker/webrtc/session.ts
22168
+ /**
22169
+ * Adaptive WebRTC session — extends the base session pattern with:
22170
+ * - RTCP Receiver Report monitoring (packet loss, jitter, RTT)
22171
+ * - Source replacement (replaceTrack for seamless quality switching)
22172
+ * - Stats emission for the AdaptiveController
22173
+ *
22174
+ * Uses werift (optional peer dependency) for server-side WebRTC.
22175
+ */
20946
22176
  var _werift;
20947
22177
  async function loadWerift() {
20948
22178
  if (_werift) return _werift;
@@ -21249,6 +22479,10 @@ var AdaptiveSession = class AdaptiveSession {
21249
22479
  /** Log the camera's actual H.264 profile-level-id once per session. */
21250
22480
  profileLogged = false;
21251
22481
  onStats;
22482
+ consumerKind;
22483
+ /** The last loss sample actually WRITTEN, and how many ticks ago. */
22484
+ lastLossWritten = null;
22485
+ lossTicksSinceWrite = 0;
21252
22486
  debug;
21253
22487
  sourceCodec;
21254
22488
  /**
@@ -21558,6 +22792,7 @@ var AdaptiveSession = class AdaptiveSession {
21558
22792
  this.intercom = options.intercom;
21559
22793
  this.iceConfig = options.iceConfig;
21560
22794
  this.onStats = options.onStats;
22795
+ this.consumerKind = options.consumerKind ?? "unknown";
21561
22796
  this.debug = options.debug ?? false;
21562
22797
  this.sourceCodec = options.sourceCodec ?? "H264";
21563
22798
  this.transcodeToBaseline = options.transcodeToBaseline ?? false;
@@ -23560,6 +24795,37 @@ var AdaptiveSession = class AdaptiveSession {
23560
24795
  selectedRemoteType: nominated?.remoteType ?? "none",
23561
24796
  selectedRemoteAddr: nominated?.remoteAddr ?? "none"
23562
24797
  } });
24798
+ const rembRaw = this.videoSender?.receiverEstimatedMaxBitrate;
24799
+ const rembBps = typeof rembRaw === "bigint" && rembRaw > 0n ? Number(rembRaw) : -1;
24800
+ const sample = {
24801
+ hasReceiverReport: report !== void 0,
24802
+ lossPct: report ? Math.round(fractionLost / 256 * 100) : -1
24803
+ };
24804
+ this.lossTicksSinceWrite += 1;
24805
+ const verdict = shouldWriteLossLine({
24806
+ sample,
24807
+ lastWritten: this.lastLossWritten,
24808
+ ticksSinceWrite: this.lossTicksSinceWrite
24809
+ });
24810
+ if (verdict.write) {
24811
+ this.lastLossWritten = sample;
24812
+ this.lossTicksSinceWrite = 0;
24813
+ this.logger.info("webrtc loss", {
24814
+ tags: { deviceId: this.deviceId },
24815
+ meta: {
24816
+ sessionId: this.sessionId,
24817
+ consumerKind: this.consumerKind,
24818
+ why: verdict.reason,
24819
+ hasReceiverReport: sample.hasReceiverReport,
24820
+ rembBps,
24821
+ lossPct: sample.lossPct,
24822
+ packetsLost,
24823
+ jitterMs: report ? Math.round(jitter / 90) : -1,
24824
+ rttMs: report ? Math.round(rtt * 1e3) : -1,
24825
+ rtpPacketsSent: this.rtpPacketsSent
24826
+ }
24827
+ });
24828
+ }
23563
24829
  if (report && this.onStats) this.onStats({
23564
24830
  sessionId: this.sessionId,
23565
24831
  packetLoss: fractionLost / 256,
@@ -28788,7 +30054,8 @@ var BrokerWebrtcServer = class {
28788
30054
  logger: sessionLogger,
28789
30055
  deviceId,
28790
30056
  rtpBootstrap: useRtpRepacketizer ? () => broker.getRtpPreBuffer() : void 0,
28791
- debug: opts.streamingDebug === true
30057
+ debug: opts.streamingDebug === true,
30058
+ consumerKind: baseAttribution.kind
28792
30059
  });
28793
30060
  const seedFromSdp = (ps) => {
28794
30061
  if (sessionCodec === "H265") session.seedH265CodecInfoFromSdp(ps);
@@ -29171,7 +30438,11 @@ var BrokerWebrtcServer = class {
29171
30438
  /** Live signaling state for a session — used by the webrtc-session
29172
30439
  * provider's getSessionState. Returns a null pending when unknown. */
29173
30440
  getSessionRenegotiationState(sessionId) {
29174
- return { pendingRenegotiation: this.sessions.get(sessionId)?.session.pendingRenegotiation ?? null };
30441
+ const entry = this.sessions.get(sessionId);
30442
+ return {
30443
+ pendingRenegotiation: entry?.session.pendingRenegotiation ?? null,
30444
+ alive: entry !== void 0
30445
+ };
29175
30446
  }
29176
30447
  /**
29177
30448
  * Wire an {@link AdaptiveController} to a freshly-created adaptive session.
@@ -29581,12 +30852,15 @@ var CameraStreamsProvider = class {
29581
30852
  const accept = requirements.acceptCodecs ?? [];
29582
30853
  const minHeight = requirements.minHeight ?? 0;
29583
30854
  const minWidth = requirements.minWidth ?? 0;
30855
+ const maxHeight = requirements.maxHeight ?? Number.POSITIVE_INFINITY;
30856
+ const allowTranscode = requirements.allowTranscode === true;
29584
30857
  const candidates = streams.filter((s) => {
29585
30858
  if (!isSelectable(s)) return false;
29586
30859
  if (excludeDerived && s.camStreamId.startsWith("derived:")) return false;
29587
- if (accept.length > 0 && !codecMatches(s.codec, accept)) return false;
30860
+ if (!allowTranscode && accept.length > 0 && !codecMatches(s.codec, accept)) return false;
29588
30861
  if ((s.resolution?.height ?? 0) < minHeight) return false;
29589
30862
  if ((s.resolution?.width ?? 0) < minWidth) return false;
30863
+ if ((s.resolution?.height ?? 0) > maxHeight) return false;
29590
30864
  return true;
29591
30865
  });
29592
30866
  if (candidates.length === 0) {
@@ -29631,24 +30905,35 @@ var CameraStreamsProvider = class {
29631
30905
  for (let i = 0; i < preferredProviders.length; i += 1) if (camStreamId.startsWith(preferredProviders[i])) return i;
29632
30906
  return preferredProviders.length;
29633
30907
  };
29634
- candidates.sort((a, b) => {
29635
- const ra = providerRank(a.camStreamId);
29636
- const rb = providerRank(b.camStreamId);
29637
- if (ra !== rb) return ra - rb;
29638
- const ha = a.resolution?.height ?? 0;
29639
- const hb = b.resolution?.height ?? 0;
29640
- return resolutionPreference === "highest" ? hb - ha : ha - hb;
29641
- });
29642
- const winner = candidates[0];
30908
+ const ranked = require_dist.rankStreams(candidates.map((s) => ({
30909
+ camStreamId: s.camStreamId,
30910
+ ...s.codec === void 0 ? {} : { codec: s.codec },
30911
+ ...s.resolution ? {
30912
+ height: s.resolution.height,
30913
+ width: s.resolution.width
30914
+ } : {}
30915
+ })), {
30916
+ acceptCodecs: accept,
30917
+ allowTranscode,
30918
+ preferredProviders,
30919
+ resolutionPreference,
30920
+ ...preferences?.targetHeight === void 0 ? {} : { targetHeight: preferences.targetHeight }
30921
+ });
30922
+ if (ranked === null) return null;
30923
+ const winner = candidates.find((s) => s.camStreamId === ranked.candidate.camStreamId);
30924
+ if (winner === void 0) return null;
29643
30925
  const reasonParts = [];
29644
30926
  reasonParts.push(`codec=${winner.codec ?? "unknown"}`);
29645
30927
  if (winner.resolution) reasonParts.push(`res=${winner.resolution.width}x${winner.resolution.height}`);
29646
30928
  reasonParts.push(`provider=${providerRank(winner.camStreamId)}`);
30929
+ reasonParts.push(ranked.transcodes ? "transcode" : "passthrough");
30930
+ if (preferences?.targetHeight !== void 0) reasonParts.push(`target=${preferences.targetHeight}`);
29647
30931
  if (requireSibling.length > 0) reasonParts.push(`bypassed=${requireSibling.join("|")}`);
29648
30932
  return {
29649
30933
  camStreamId: winner.camStreamId,
29650
30934
  codec: winner.codec,
29651
30935
  resolution: winner.resolution,
30936
+ transcodes: ranked.transcodes,
29652
30937
  reason: reasonParts.join(" ")
29653
30938
  };
29654
30939
  }
@@ -30120,6 +31405,11 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
30120
31405
  const localNode = this.ctx.kernel.localNodeId;
30121
31406
  const localNodeId = localNode ? localNode.includes("/") ? localNode.split("/")[0] : localNode : void 0;
30122
31407
  this.brokerManager.setApiAccess(this.ctx.api, localNodeId, this.ctx.kernel.hwaccel);
31408
+ const readSignals = async (deviceId) => {
31409
+ return await (await this.ctx.fetchDevice(deviceId)).streamSignals?.listSignals({}) ?? [];
31410
+ };
31411
+ this.brokerManager.setDeviceSignalReader(async (deviceId) => (await readSignals(deviceId)).map((s) => s.code));
31412
+ this.brokerManager.setDeviceSignalDetailReader(readSignals);
30123
31413
  await this.loadFfmpegConfig();
30124
31414
  await this.declareRowCollections();
30125
31415
  await this.purgeFlattenedSettingsKeys();