@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
@@ -1,10 +1,10 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { $t as isEvent, A as RingBuffer, Dt as resolveEgressDecodeHwAccel, E as RATE_CONTROL_TIGHT, Gt as BaseAddon, Ht as invocationFromEncodeProfile, I as batteryCapability, J as egressTranscodeSharingKey, Jt as DeviceType, Kt as CAM_PROFILE_ORDER, L as cameraStreamsCapability, Lt as webrtcSessionCapability, M as addonWidgetsSourceCapability, Pt as streamBrokerCapability, R as createHwAccelCache, Rt as errMsg, T as RATE_CONTROL_RELAXED, Ut as isSoftwareDecode, V as declareLogChannel, Vt as buildFfmpegArgs$1, W as deriveBatteryPresence, Wt as BOOT_RECOVERY_BACKOFF_MS, Xt as createEvent, Y as egressTransportFromRequest, Zt as expandAudioChunkToF32le, _n as string, cn as array, en as makeProfileBrokerId, fn as literal, gn as record, h as EncodeProfileSchema, i as BatteryStatusSchema, in as parseProfileBrokerId, ln as boolean, lt as maskUrlCredentials, mn as object, nn as nodePin, ot as loadContributionCapability, pn as number, qt as DeviceFeature, sn as _enum, st as logChannelsCapability, tn as makeSourceBrokerId, un as discriminatedUnion, v as G711_SCALE_CORRECTION_DB, vn as union, yn as EventCategory, z as createLogChannelsProvider, zt as AUDIO_PRESETS } from "../dist-Dcp-eVbN.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-ClxCG7kX.mjs";
4
- import { c as PREBUFFER_MAX_MS, d as resolvePrebuffer, i as parseRestreamPath, l as capPreRollRequestMs, n as NO_DECLARATION, u as prebufferRetentionMs } from "../restream-intent-DWe2kInY.mjs";
2
+ import { A as RingBuffer, Bt as AUDIO_PRESETS, Ct as rankStreams, E as RATE_CONTROL_TIGHT, Ft as streamBrokerCapability, Gt as BOOT_RECOVERY_BACKOFF_MS, Ht as buildFfmpegArgs$1, I as batteryCapability, J as egressTranscodeSharingKey, Jt as DeviceFeature, Kt as BaseAddon, L as cameraStreamsCapability, M as addonWidgetsSourceCapability, Ot as resolveEgressDecodeHwAccel, Qt as expandAudioChunkToF32le, R as createHwAccelCache, Rt as webrtcSessionCapability, T as RATE_CONTROL_RELAXED, Ut as invocationFromEncodeProfile, V as declareLogChannel, W as deriveBatteryPresence, Wt as isSoftwareDecode, Y as egressTransportFromRequest, Yt as DeviceType, Zt as createEvent, _n as record, an as parseProfileBrokerId, bn as EventCategory, cn as _enum, dn as discriminatedUnion, en as isEvent, h as EncodeProfileSchema, hn as object, i as BatteryStatusSchema, ln as array, lt as maskUrlCredentials, mn as number, nn as makeSourceBrokerId, ot as loadContributionCapability, pn as literal, qt as CAM_PROFILE_ORDER, rn as nodePin, st as logChannelsCapability, tn as makeProfileBrokerId, un as boolean, v as G711_SCALE_CORRECTION_DB, vn as string, yn as union, z as createLogChannelsProvider, zt as errMsg } from "../dist-DTPYOq7J.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-Cz92fVGW.mjs";
4
+ import { c as PREBUFFER_MAX_MS, d as resolvePrebuffer, i as parseRestreamPath, l as capPreRollRequestMs, n as NO_DECLARATION, u as prebufferRetentionMs } from "../restream-intent-MeJhGCK9.mjs";
5
5
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
6
6
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
7
- import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-BWZQJ1AS.mjs";
7
+ import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-CyaNDtrw.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as os from "node:os";
10
10
  import { networkInterfaces, tmpdir } from "node:os";
@@ -17,6 +17,7 @@ import { join } from "node:path";
17
17
  import { spawn } from "node:child_process";
18
18
  import { readFile } from "node:fs/promises";
19
19
  import { fileURLToPath, pathToFileURL } from "node:url";
20
+ import { renderCourtesyJpeg, renderCourtesyOverlayJpeg } from "@camstack/system/addon-utils";
20
21
  import * as net$1 from "node:net";
21
22
  import net from "node:net";
22
23
  import { createSocket } from "node:dgram";
@@ -556,6 +557,240 @@ function rankDecodeHwAccels(preferred, supportedMethods) {
556
557
  return candidates.sort((a, b) => decodeHwAccelRankIndex(a) - decodeHwAccelRankIndex(b));
557
558
  }
558
559
  //#endregion
560
+ //#region src/stream-broker/stream-broker/egress-source-readiness.ts
561
+ /**
562
+ * Whether waiting on this source can achieve anything — the three-way answer
563
+ * `waitForSourceMedia` consumes.
564
+ *
565
+ * The subtlety that cost a measured 15 s per cold Alexa open on 2026-09-13: a
566
+ * lazy publisher materialises because someone DIALS it. The source broker's own
567
+ * `redial` / `kickReconnect` both refuse without demand, by design — a broker
568
+ * with no consumer must not wake a camera. So for a cold, idle source the
569
+ * ffmpeg child IS the demand, and withholding it waits for media that only
570
+ * starts because of the thing being withheld. The live log of that mistake:
571
+ *
572
+ * 19:04:27 waited for the SOURCE {outcome=timeout waitedMs=15005 ready=false}
573
+ * 19:04:30 the child died before the decoder — retrying in SOFTWARE
574
+ * 19:04:32 rfc4571: publisher materialized {materializeMs=5798}
575
+ *
576
+ * Fifteen seconds of nothing, then the spawn, then the wake it had triggered.
577
+ *
578
+ * So: wait only when a materialize is ALREADY in flight — a second consumer
579
+ * arriving while the first is waking the camera, which is the case the wait was
580
+ * really for. A cold idle source answers `null`: not "no media", but "waiting
581
+ * here cannot help", and the caller spawns at once.
582
+ */
583
+ function sourceWaitState(input) {
584
+ if (input.hasMedia) return true;
585
+ if (input.materializing) return false;
586
+ return null;
587
+ }
588
+ async function waitForSourceMedia(input) {
589
+ const started = input.now();
590
+ const first = input.hasMedia();
591
+ if (first === null) return {
592
+ ready: false,
593
+ waitedMs: 0,
594
+ outcome: "unknown"
595
+ };
596
+ if (first) return {
597
+ ready: true,
598
+ waitedMs: 0,
599
+ outcome: "live"
600
+ };
601
+ for (;;) {
602
+ const elapsed = input.now() - started;
603
+ if (elapsed >= input.timeoutMs) return {
604
+ ready: false,
605
+ waitedMs: elapsed,
606
+ outcome: "timeout"
607
+ };
608
+ await input.sleep(input.pollMs);
609
+ const state = input.hasMedia();
610
+ const waitedMs = input.now() - started;
611
+ if (state === null) return {
612
+ ready: false,
613
+ waitedMs,
614
+ outcome: "unknown"
615
+ };
616
+ if (state) return {
617
+ ready: true,
618
+ waitedMs,
619
+ outcome: "materialized"
620
+ };
621
+ }
622
+ }
623
+ //#endregion
624
+ //#region src/stream-broker/stream-broker/stream-health-scope.ts
625
+ /**
626
+ * WHICH streams a health transition is worth telling the operator about.
627
+ *
628
+ * `stream.online` / `stream.offline` are operator-facing: they reach the
629
+ * notification rules and land on a phone. So they must be facts about a
630
+ * CAMERA, and an egress stream is not one — it is the private pipe an exporter
631
+ * asks the broker to build for one consumer session and drops when that session
632
+ * ends. Its whole life is an implementation detail of somebody watching.
633
+ *
634
+ * Measured on this hub 2026-09-13: every Alexa session on the baby monitor
635
+ * produced a `stream.online` and a `stream.offline` pair against
636
+ * `640/egress:8e96e97a…`, and the operator read them as the battery camera
637
+ * flapping during its wake. Nothing was flapping — that is just what an egress
638
+ * pipe does, once per session, by design.
639
+ *
640
+ * The doctrine already existed one layer down: an egress is published with
641
+ * `autoEligible: false` so "a private egress can never become a camera's
642
+ * published stream". It simply had not reached the event path.
643
+ *
644
+ * A DERIVED stream is different and stays in scope: the operator configured it,
645
+ * it is meant to persist, and its going offline is news.
646
+ */
647
+ /** Private, per-session egress pipes. See {@link healthEventIsOperatorFacing}. */
648
+ var PRIVATE_STREAM_PREFIXES = ["egress:"];
649
+ function healthEventIsOperatorFacing(camStreamId) {
650
+ return !PRIVATE_STREAM_PREFIXES.some((prefix) => camStreamId.startsWith(prefix));
651
+ }
652
+ //#endregion
653
+ //#region src/stream-broker/stream-broker/prestream-eligibility.ts
654
+ function prestreamEligible(input) {
655
+ if (input.batteryOperated === null || input.declaresSignals === null) return {
656
+ eligible: false,
657
+ reason: "unknown"
658
+ };
659
+ if (!input.batteryOperated) return {
660
+ eligible: false,
661
+ reason: "not-battery"
662
+ };
663
+ if (!input.declaresSignals) return {
664
+ eligible: false,
665
+ reason: "no-signals-cap"
666
+ };
667
+ return { eligible: true };
668
+ }
669
+ //#endregion
670
+ //#region src/stream-broker/stream-broker/prestream-signal-selection.ts
671
+ function nextEnabledSignals(input) {
672
+ const base = input.stored ?? input.declared;
673
+ const set = new Set(base);
674
+ if (input.enabled) set.add(input.code);
675
+ else set.delete(input.code);
676
+ const known = input.declared.filter((c) => set.has(c));
677
+ const extra = [...set].filter((c) => !input.declared.includes(c));
678
+ return [...known, ...extra];
679
+ }
680
+ //#endregion
681
+ //#region src/stream-broker/stream-broker/wake-hold.ts
682
+ /**
683
+ * The HOLD a camera's own signal buys, and what it may not buy.
684
+ *
685
+ * A stream a consumer merely watches is not a reason to wake a battery camera —
686
+ * a Frigate-style puller holds a session open for ever, and treating that as
687
+ * demand keeps the camera awake for ever, which is the whole point of the
688
+ * battery (D173). So a prestream consumer is FED without being INTENT, and the
689
+ * only thing that dials is a signal the camera reported about itself.
690
+ *
691
+ * This module is the timing half of that, and it is pure so the arithmetic can
692
+ * be argued with. Three rules:
693
+ *
694
+ * - **A hold EXTENDS, it never stacks.** Continuous motion keeps a camera up
695
+ * and it sleeps `holdMs` after the LAST signal, not after the sum of them.
696
+ * A counter or a queue would turn a busy afternoon into an hour of wake.
697
+ * - **A run is bounded ABSOLUTELY, from its first signal.** A stuck PIR
698
+ * reporting every second would otherwise extend the hold indefinitely and
699
+ * drain the battery — the one failure this whole design exists to prevent.
700
+ * The same re-arm-under-a-ceiling shape D371 gives `recording-signal`.
701
+ * - **A deadline, not a chain of timers.** The state is one number; the timer
702
+ * only decides when to LOOK at it. A chain would drift and would have to be
703
+ * cancelled correctly on every path.
704
+ */
705
+ /** How long a camera stays awake after the LAST signal. The operator's, per
706
+ * camera; this is the default the broker ships. */
707
+ var DEFAULT_WAKE_HOLD_MS = 15e3;
708
+ /**
709
+ * Ceiling on ONE run, measured from its first signal.
710
+ *
711
+ * Five minutes. Long enough that a genuine sustained event — a person moving
712
+ * around a garden — is covered end to end; short enough that a sensor stuck
713
+ * ON costs one five-minute wake rather than a night.
714
+ */
715
+ var WAKE_HOLD_MAX_RUN_MS = 5 * 6e4;
716
+ /**
717
+ * How long a camera is left alone after a run hit the ceiling.
718
+ *
719
+ * Without this the ceiling is cosmetic, and the test that found it says why: a
720
+ * run ends exactly AT the ceiling, so the very next signal from a sensor that
721
+ * never stops re-arms a fresh five minutes. Five minutes awake, an instant
722
+ * asleep, five minutes awake — all night, which is the precise failure the
723
+ * ceiling was written to prevent.
724
+ *
725
+ * So a capped run buys silence. Ten minutes: long enough that a stuck sensor
726
+ * costs a third of its time rather than all of it, short enough that a genuine
727
+ * long event — someone working in the garden — resumes within a few minutes
728
+ * instead of being locked out for an hour.
729
+ *
730
+ * Signals during the quarantine are REFUSED and counted, not queued: a queue
731
+ * would fire them all the moment it lifts, which is the same drain arriving
732
+ * late.
733
+ */
734
+ var WAKE_QUARANTINE_AFTER_CAP_MS = 10 * 6e4;
735
+ var NO_HOLD = {
736
+ runStartedAt: 0,
737
+ until: 0,
738
+ signals: 0,
739
+ quarantinedUntil: 0
740
+ };
741
+ /**
742
+ * Absorb one signal.
743
+ *
744
+ * A signal arriving with the run already at its ceiling does NOT extend it —
745
+ * and does not start a new one either, because a new run measured from now
746
+ * would be the same unbounded extension wearing a different name. It is
747
+ * reported as `capped` so the operator can see a sensor that will not stop.
748
+ */
749
+ function absorbSignal(input) {
750
+ const { current, now, holdMs } = input;
751
+ if (!(current.until > now && current.runStartedAt > 0)) {
752
+ if (current.quarantinedUntil > now) return {
753
+ hold: {
754
+ ...current,
755
+ signals: current.signals + 1
756
+ },
757
+ outcome: "quarantined"
758
+ };
759
+ return {
760
+ hold: {
761
+ runStartedAt: now,
762
+ until: now + holdMs,
763
+ signals: 1,
764
+ quarantinedUntil: 0
765
+ },
766
+ outcome: "armed"
767
+ };
768
+ }
769
+ if (now - current.runStartedAt >= 3e5) return {
770
+ hold: {
771
+ ...current,
772
+ signals: current.signals + 1,
773
+ quarantinedUntil: now + WAKE_QUARANTINE_AFTER_CAP_MS
774
+ },
775
+ outcome: "capped"
776
+ };
777
+ const ceiling = current.runStartedAt + WAKE_HOLD_MAX_RUN_MS;
778
+ const until = Math.min(now + holdMs, ceiling);
779
+ return {
780
+ hold: {
781
+ ...current,
782
+ until,
783
+ signals: current.signals + 1,
784
+ ...until === ceiling ? { quarantinedUntil: ceiling + WAKE_QUARANTINE_AFTER_CAP_MS } : {}
785
+ },
786
+ outcome: "extended"
787
+ };
788
+ }
789
+ /** Whether the hold still keeps the camera awake at `now`. */
790
+ function holdIsLive(hold, now) {
791
+ return hold.until > now;
792
+ }
793
+ //#endregion
559
794
  //#region src/stream-broker/battery-wake-on-play.ts
560
795
  /**
561
796
  * Wake-on-play — pressing play on a sleeping battery camera wakes it, and the
@@ -1244,7 +1479,7 @@ var ADTS_SAMPLE_RATES = [
1244
1479
  /** AAC-LC AudioObjectType for the 2-byte AudioSpecificConfig. */
1245
1480
  var AAC_LC_OBJECT_TYPE = 2;
1246
1481
  /** Grace before escalating SIGTERM → SIGKILL on a lingering child. */
1247
- var KILL_GRACE_MS = 500;
1482
+ var KILL_GRACE_MS$1 = 500;
1248
1483
  /**
1249
1484
  * Parse the 7-byte ADTS header of an AAC frame into the audio-init params the
1250
1485
  * broker's push ingress needs. Returns `null` when the buffer is too short or
@@ -1438,7 +1673,7 @@ var FfmpegPushReader = class {
1438
1673
  if (pid !== void 0 && child.exitCode === null && child.signalCode === null) try {
1439
1674
  child.kill("SIGKILL");
1440
1675
  } catch {}
1441
- }, KILL_GRACE_MS).unref?.();
1676
+ }, KILL_GRACE_MS$1).unref?.();
1442
1677
  }
1443
1678
  };
1444
1679
  //#endregion
@@ -2890,6 +3125,26 @@ var RtspRestreamer = class {
2890
3125
  getSessionCount() {
2891
3126
  return this.sessions.size;
2892
3127
  }
3128
+ /**
3129
+ * Sessions that count as LIVE INTENT — everything except a prestream dial.
3130
+ *
3131
+ * The split exists so a Frigate-style puller can hold a session open for ever
3132
+ * against a battery camera without keeping it awake (D173). For a broker with
3133
+ * no prestream session this equals {@link getSessionCount} exactly, which is
3134
+ * why nothing else changed behaviour.
3135
+ */
3136
+ getLiveIntentSessionCount() {
3137
+ let n = 0;
3138
+ for (const session of this.sessions.values()) if (!session.isPrestreamIntent()) n += 1;
3139
+ return n;
3140
+ }
3141
+ /** Sessions asking only for the picture. Drives the prestream pump, nothing
3142
+ * else — it must never reach a dial decision. */
3143
+ getPrestreamSessionCount() {
3144
+ let n = 0;
3145
+ for (const session of this.sessions.values()) if (session.isPrestreamIntent()) n += 1;
3146
+ return n;
3147
+ }
2893
3148
  /** Per-session diagnostic snapshots consumed by `StreamBroker.listClients`. */
2894
3149
  listSessionInfos() {
2895
3150
  return [...this.sessions.values()].map((s) => s.getInfo());
@@ -3642,6 +3897,16 @@ var RtspSession = class {
3642
3897
  return this.declaration.intent === "detection";
3643
3898
  }
3644
3899
  /**
3900
+ * Whether this dial asked for the STREAM without asking for the CAMERA.
3901
+ *
3902
+ * The one question the demand split turns on: a prestream session is FED but
3903
+ * is not INTENT, so it never dials a sleeping camera. See
3904
+ * `PRESTREAM_PATH_SUFFIX`.
3905
+ */
3906
+ isPrestreamIntent() {
3907
+ return this.declaration.intent === "prestream";
3908
+ }
3909
+ /**
3645
3910
  * Ms of retained history this session ASKED for on its dial. `0` = none.
3646
3911
  *
3647
3912
  * Read by `RtspRestreamer.servePendingSessionsRtp`, which serves the smaller
@@ -5009,41 +5274,19 @@ function isDecodeBackendFailure(error) {
5009
5274
  return true;
5010
5275
  }
5011
5276
  //#endregion
5012
- //#region src/stream-broker/stream-broker/egress-source-readiness.ts
5013
- async function waitForSourceMedia(input) {
5014
- const started = input.now();
5015
- const first = input.hasMedia();
5016
- if (first === null) return {
5017
- ready: false,
5018
- waitedMs: 0,
5019
- outcome: "unknown"
5020
- };
5021
- if (first) return {
5022
- ready: true,
5023
- waitedMs: 0,
5024
- outcome: "live"
5277
+ //#region src/stream-broker/stream-broker/egress-geometry.ts
5278
+ function capAxis(requested, source) {
5279
+ if (requested === void 0) return void 0;
5280
+ if (source === void 0 || source <= 0) return requested;
5281
+ return Math.min(requested, source);
5282
+ }
5283
+ function deliveredGeometry(input) {
5284
+ const width = capAxis(input.requestedWidth, input.sourceWidth);
5285
+ const height = capAxis(input.requestedHeight, input.sourceHeight);
5286
+ return {
5287
+ ...width === void 0 ? {} : { width },
5288
+ ...height === void 0 ? {} : { height }
5025
5289
  };
5026
- for (;;) {
5027
- const elapsed = input.now() - started;
5028
- if (elapsed >= input.timeoutMs) return {
5029
- ready: false,
5030
- waitedMs: elapsed,
5031
- outcome: "timeout"
5032
- };
5033
- await input.sleep(input.pollMs);
5034
- const state = input.hasMedia();
5035
- const waitedMs = input.now() - started;
5036
- if (state === null) return {
5037
- ready: false,
5038
- waitedMs,
5039
- outcome: "unknown"
5040
- };
5041
- if (state) return {
5042
- ready: true,
5043
- waitedMs,
5044
- outcome: "materialized"
5045
- };
5046
- }
5047
5290
  }
5048
5291
  //#endregion
5049
5292
  //#region src/stream-broker/stream-broker/rtsp-host.ts
@@ -5429,6 +5672,17 @@ var SOURCE_MEDIA_WAIT_MS = 15e3;
5429
5672
  /** Poll cadence for that wait. Flat, for the reason a materialize poll is. */
5430
5673
  var SOURCE_MEDIA_POLL_MS = 250;
5431
5674
  /**
5675
+ * How long to wait before re-dialling a source whose input was not there.
5676
+ *
5677
+ * The first spawn is what WAKES a lazy publisher, and the measured wake on this
5678
+ * hub lands between 2.2 s and 7.8 s (`materializeMs`). Three seconds is inside
5679
+ * that band: long enough that a quick wake is already serving, short enough
5680
+ * that the second attempt still beats the consumer's own patience.
5681
+ */
5682
+ var INPUT_RETRY_DELAY_MS = 3e3;
5683
+ /** After the first three, log one keyframe in this many. */
5684
+ var KEYFRAME_LOG_EVERY = 25;
5685
+ /**
5432
5686
  * How long a push child may produce nothing before it is declared dead. The
5433
5687
  * observed failure EXITS in ~2 s and is caught by the error path long before
5434
5688
  * this; the timeout only covers a child that hangs.
@@ -5594,12 +5848,18 @@ var EgressTranscodeManager = class {
5594
5848
  const decodeHwAccel = await this.opts.resolveDecodeHwAccel(deviceId, request.decodeHwAccel);
5595
5849
  const fragments = delivery.transport === "fragments";
5596
5850
  const publishedCamStreamId = `egress:${key.replace(/^egress:/, "")}`;
5851
+ const delivered = deliveredGeometry({
5852
+ requestedWidth: request.encode.video.width,
5853
+ requestedHeight: request.encode.video.height,
5854
+ sourceWidth: source.facts.width,
5855
+ sourceHeight: source.facts.height
5856
+ });
5597
5857
  const pushSink = fragments || request.publishLocally !== true || this.opts.publishPushStream === void 0 ? null : await this.opts.publishPushStream({
5598
5858
  deviceId,
5599
5859
  camStreamId: publishedCamStreamId,
5600
5860
  codec: request.encode.video.codec,
5601
- width: request.encode.video.width ?? 0,
5602
- height: request.encode.video.height ?? 0
5861
+ width: delivered.width ?? 0,
5862
+ height: delivered.height ?? 0
5603
5863
  });
5604
5864
  if (request.publishLocally === true && pushSink === null && !fragments) this.opts.logger.warn("egress transcode: local publication REFUSED — the consumer must dial the url", {
5605
5865
  tags: { deviceId },
@@ -5613,7 +5873,10 @@ var EgressTranscodeManager = class {
5613
5873
  const fragmentSeconds = fragments ? (delivery.fragmentMs ?? 0) / 1e3 : void 0;
5614
5874
  const { sink: _sink, audioSidecar: _audioSidecar, ...plan } = invocationFromEncodeProfile({
5615
5875
  profile: {
5616
- video: request.encode.video,
5876
+ video: {
5877
+ ...request.encode.video,
5878
+ ...delivered
5879
+ },
5617
5880
  audio: audio.profileAudio
5618
5881
  },
5619
5882
  sourceCodec: source.codec,
@@ -6045,6 +6308,18 @@ var EgressTranscodeManager = class {
6045
6308
  dts: packet.dts
6046
6309
  }
6047
6310
  });
6311
+ } else if (packet.keyframe === true) {
6312
+ keyframesOut += 1;
6313
+ if (keyframesOut <= 3 || keyframesOut % KEYFRAME_LOG_EVERY === 0) logger.info("egress push: keyframe out", {
6314
+ tags: { deviceId },
6315
+ meta: {
6316
+ brokerId: spec.brokerId,
6317
+ camStreamId: spec.publishedCamStreamId,
6318
+ keyframesOut,
6319
+ bytes: packet.data.length,
6320
+ approxRtpPackets: Math.ceil(packet.data.length / 1200)
6321
+ }
6322
+ });
6048
6323
  } else if (packetsOut % PUSH_PACKET_LOG_EVERY === 0) logger.info("egress push: packets still flowing out of the ffmpeg child", {
6049
6324
  tags: { deviceId },
6050
6325
  meta: {
@@ -6102,30 +6377,66 @@ var EgressTranscodeManager = class {
6102
6377
  reader.connect();
6103
6378
  });
6104
6379
  };
6380
+ let keyframesOut = 0;
6105
6381
  let hwAccelAtFault = true;
6106
6382
  return {
6107
6383
  hwAccelAtFault: () => hwAccelAtFault,
6108
6384
  start: async () => {
6109
6385
  const requested = spec.invocation.decodeHwAccel;
6386
+ /** Whether a same-decode retry already produced a live child. */
6387
+ let recoveredOnRetry = false;
6388
+ const inputRetryDelayMs = this.opts.inputRetryDelayMs ?? INPUT_RETRY_DELAY_MS;
6110
6389
  try {
6111
6390
  await spawnAttempt(requested);
6112
6391
  } catch (err) {
6113
6392
  if (requested === null || isSoftwareDecode(requested)) throw err;
6114
6393
  const blameBackend = isDecodeBackendFailure(errMsg(err));
6115
6394
  hwAccelAtFault = blameBackend;
6116
- 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", {
6117
- tags: { deviceId },
6118
- meta: {
6119
- brokerId: spec.brokerId,
6120
- camStreamId: spec.publishedCamStreamId,
6121
- decodeHwAccel: requested,
6122
- blameBackend,
6123
- error: errMsg(err)
6395
+ if (!blameBackend) {
6396
+ logger.info("egress push: the input was not there — retrying the SAME decode", {
6397
+ tags: { deviceId },
6398
+ meta: {
6399
+ brokerId: spec.brokerId,
6400
+ camStreamId: spec.publishedCamStreamId,
6401
+ decodeHwAccel: requested,
6402
+ afterMs: inputRetryDelayMs,
6403
+ error: errMsg(err)
6404
+ }
6405
+ });
6406
+ reader?.destroy();
6407
+ await new Promise((resolve) => {
6408
+ setTimeout(resolve, inputRetryDelayMs).unref?.();
6409
+ });
6410
+ try {
6411
+ await spawnAttempt(requested);
6412
+ recoveredOnRetry = true;
6413
+ } catch (retryErr) {
6414
+ logger.warn("egress push: the input was STILL not there — falling to SOFTWARE", {
6415
+ tags: { deviceId },
6416
+ meta: {
6417
+ brokerId: spec.brokerId,
6418
+ camStreamId: spec.publishedCamStreamId,
6419
+ decodeHwAccel: requested,
6420
+ error: errMsg(retryErr)
6421
+ }
6422
+ });
6124
6423
  }
6125
- });
6126
- reader?.destroy();
6127
- activeHwAccel = null;
6128
- await spawnAttempt(null);
6424
+ }
6425
+ if (!recoveredOnRetry) {
6426
+ 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", {
6427
+ tags: { deviceId },
6428
+ meta: {
6429
+ brokerId: spec.brokerId,
6430
+ camStreamId: spec.publishedCamStreamId,
6431
+ decodeHwAccel: requested,
6432
+ blameBackend,
6433
+ error: errMsg(err)
6434
+ }
6435
+ });
6436
+ reader?.destroy();
6437
+ activeHwAccel = null;
6438
+ await spawnAttempt(null);
6439
+ }
6129
6440
  }
6130
6441
  logger.info("egress transcode: publishing LOCALLY — no rtsp round trip", {
6131
6442
  tags: { deviceId },
@@ -6875,13 +7186,13 @@ var EVENT_CLIP_MAX_HEIGHT = 1080;
6875
7186
  * `pickClipProfile` and the answer reports which was used.
6876
7187
  */
6877
7188
  function pickBestClipProfile(candidates) {
6878
- const usable = candidates.filter((c) => normaliseCodec$1(c.codec) === "h264" && (c.height ?? 0) > 0 && (c.width ?? 0) > 0);
7189
+ const usable = candidates.filter((c) => normaliseCodec$2(c.codec) === "h264" && (c.height ?? 0) > 0 && (c.width ?? 0) > 0);
6879
7190
  if (usable.length === 0) return null;
6880
7191
  const within = usable.filter((c) => (c.height ?? 0) <= EVENT_CLIP_MAX_HEIGHT);
6881
7192
  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;
6882
7193
  }
6883
7194
  /** Publishers spell H.265 four ways; the same normalisation the broker uses. */
6884
- function normaliseCodec$1(codec) {
7195
+ function normaliseCodec$2(codec) {
6885
7196
  return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
6886
7197
  }
6887
7198
  /**
@@ -7327,6 +7638,249 @@ function runGif(input, args) {
7327
7638
  });
7328
7639
  }
7329
7640
  //#endregion
7641
+ //#region src/stream-broker/stream-broker/prestream-encode.ts
7642
+ /**
7643
+ * ONE short-lived ffmpeg turns the prestream's picture into ONE Annex-B IDR.
7644
+ *
7645
+ * ── Why one encode and not a continuous child ─────────────────────────────
7646
+ * The obvious shape is `-loop 1 -framerate 1` and let ffmpeg emit the picture
7647
+ * for as long as the camera is waking. That is a resident child per sleeping
7648
+ * camera, for the whole wake, to redraw a picture that changes at most hourly.
7649
+ * So the child encodes exactly ONE frame (`-frames:v 1`) and exits, the caller
7650
+ * caches the bytes, and {@link PrestreamPump} re-emits them. A new encode
7651
+ * happens only when the picture changes.
7652
+ *
7653
+ * ── Why every frame carries its own parameter sets ────────────────────────
7654
+ * The pump re-emits ONE access unit at 1 fps, so a consumer that attaches on
7655
+ * tick 4 sees its first packet with nothing before it. `-g 1` plus
7656
+ * `repeat-headers=1` makes that packet a complete IDR with SPS/PPS (or
7657
+ * VPS/SPS/PPS) inline, so it decodes within a second of attaching instead of
7658
+ * waiting for a keyframe that will never come.
7659
+ *
7660
+ * ── Why the geometry comes from the plan and nowhere else ─────────────────
7661
+ * `planPrestream` refuses a camera whose geometry nobody has probed, precisely
7662
+ * so this file never has a default to fall back on. `stream-broker.ts` records
7663
+ * what a wrong one does: a fixed 1280x720 placeholder SPS in front of a
7664
+ * camera's real IDR froze Chrome's decoder on the last synthetic frame until
7665
+ * the viewer was recreated by hand. Hence `scale` mode `exact`, the plan's
7666
+ * numbers, and the plan's codec.
7667
+ *
7668
+ * ── Why no text is drawn here ─────────────────────────────────────────────
7669
+ * D118: the hub carries two ffmpeg builds and only one has libfreetype, so
7670
+ * `drawtext` is forbidden. The picture arrives already composed — sharp + an
7671
+ * inline SVG, the house pattern from `snapshot-courtesy.ts`.
7672
+ */
7673
+ /**
7674
+ * Bound on the encode. A single 4 MP still on `ultrafast` is milliseconds; a
7675
+ * child that has not answered in this long is hung, and the prestream would
7676
+ * otherwise hold a promise open for the rest of the process's life.
7677
+ */
7678
+ var PRESTREAM_ENCODE_TIMEOUT_MS = 1e4;
7679
+ /** Grace before escalating SIGTERM → SIGKILL on a child that will not go. */
7680
+ var KILL_GRACE_MS = 500;
7681
+ /** x264/x265 params shared by both codecs: one-frame GOP, headers on it. */
7682
+ var STILL_ENCODER_PARAMS = "keyint=1:repeat-headers=1";
7683
+ /**
7684
+ * The argv. PURE, so the two properties that matter — the geometry is the
7685
+ * plan's, and the frame is a self-contained IDR — are assertable without a
7686
+ * binary.
7687
+ */
7688
+ function buildPrestreamEncodeArgs(plan) {
7689
+ const h265 = plan.codec === "h265";
7690
+ return buildFfmpegArgs$1({
7691
+ logLevel: "error",
7692
+ decodeHwAccel: null,
7693
+ input: {
7694
+ url: "pipe:0",
7695
+ extraArgs: [
7696
+ "-f",
7697
+ "image2pipe",
7698
+ "-framerate",
7699
+ "1"
7700
+ ]
7701
+ },
7702
+ video: {
7703
+ kind: "encode",
7704
+ encoder: h265 ? "libx265" : "libx264",
7705
+ scale: {
7706
+ mode: "exact",
7707
+ width: plan.width,
7708
+ height: plan.height
7709
+ },
7710
+ preset: "ultrafast",
7711
+ ...h265 ? {} : { tune: "stillimage" },
7712
+ pixelFormat: "yuv420p",
7713
+ fps: 1,
7714
+ gopFrames: 1,
7715
+ bf: 0
7716
+ },
7717
+ audio: { kind: "none" },
7718
+ threadCount: 1,
7719
+ outputArgs: [
7720
+ "-frames:v",
7721
+ "1",
7722
+ h265 ? "-x265-params" : "-x264-params",
7723
+ STILL_ENCODER_PARAMS
7724
+ ],
7725
+ sink: {
7726
+ kind: "stdout",
7727
+ container: h265 ? "hevc" : "h264"
7728
+ }
7729
+ });
7730
+ }
7731
+ /**
7732
+ * Run the encode. Resolves with the Annex-B bytes, rejects with a named reason
7733
+ * — the caller turns a rejection into a refusal log and no prestream, which is
7734
+ * the same outcome as a black screen and never worse than one.
7735
+ */
7736
+ function encodePrestreamIdr(options) {
7737
+ const args = buildPrestreamEncodeArgs(options.plan);
7738
+ const spawnFn = options.spawnFn ?? spawn;
7739
+ const tags = options.deviceId === void 0 ? {} : { tags: { deviceId: options.deviceId } };
7740
+ return new Promise((resolve, reject) => {
7741
+ let child;
7742
+ try {
7743
+ child = spawnFn(options.ffmpegPath, args);
7744
+ } catch (err) {
7745
+ reject(/* @__PURE__ */ new Error(`prestream encode could not spawn ffmpeg: ${errMsg(err)}`));
7746
+ return;
7747
+ }
7748
+ const chunks = [];
7749
+ let stderrTail = "";
7750
+ let settled = false;
7751
+ const finish = (outcome) => {
7752
+ if (settled) return;
7753
+ settled = true;
7754
+ clearTimeout(timer);
7755
+ if ("error" in outcome) reject(outcome.error);
7756
+ else resolve(outcome.bytes);
7757
+ };
7758
+ const timer = setTimeout(() => {
7759
+ killChild(child);
7760
+ finish({ error: /* @__PURE__ */ new Error(`prestream encode timed out after ${String(PRESTREAM_ENCODE_TIMEOUT_MS)} ms`) });
7761
+ }, PRESTREAM_ENCODE_TIMEOUT_MS);
7762
+ timer.unref?.();
7763
+ child.stdout?.on("data", (chunk) => chunks.push(chunk));
7764
+ child.stderr?.on("data", (chunk) => {
7765
+ stderrTail = `${stderrTail}${chunk.toString("utf8")}`.slice(-2048);
7766
+ });
7767
+ child.stdin?.on("error", () => {});
7768
+ child.on("error", (err) => {
7769
+ finish({ error: /* @__PURE__ */ new Error(`prestream encode failed: ${err.message}`) });
7770
+ });
7771
+ child.on("exit", (code, signal) => {
7772
+ if (code !== 0) {
7773
+ finish({ error: /* @__PURE__ */ new Error(`prestream encode exited ${String(code ?? signal)}: ${stderrTail.trim() || "no stderr"}`) });
7774
+ return;
7775
+ }
7776
+ const bytes = Buffer.concat(chunks);
7777
+ if (bytes.length === 0) {
7778
+ finish({ error: /* @__PURE__ */ new Error("prestream encode produced no bytes") });
7779
+ return;
7780
+ }
7781
+ options.logger?.debug("prestream: picture encoded", {
7782
+ ...tags,
7783
+ meta: {
7784
+ codec: options.plan.codec,
7785
+ width: options.plan.width,
7786
+ height: options.plan.height,
7787
+ bytes: bytes.length
7788
+ }
7789
+ });
7790
+ finish({ bytes });
7791
+ });
7792
+ child.stdin?.end(options.jpeg);
7793
+ });
7794
+ }
7795
+ function killChild(child) {
7796
+ if (child.exitCode !== null || child.signalCode !== null) return;
7797
+ child.kill("SIGTERM");
7798
+ setTimeout(() => {
7799
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
7800
+ }, KILL_GRACE_MS).unref?.();
7801
+ }
7802
+ //#endregion
7803
+ //#region src/stream-broker/stream-broker/prestream-picture.ts
7804
+ /**
7805
+ * The PICTURE the prestream shows: the camera's own last frame under a "Waking"
7806
+ * treatment, or — when there is none — a generated card carrying its name.
7807
+ *
7808
+ * ── Why a fallback exists at all ──────────────────────────────────────────
7809
+ * The snapshot cache is RAM-only. After a hub restart a sleeping battery camera
7810
+ * has no cached frame, and `getSnapshot` without `force` will not wake it to
7811
+ * make one (`force` is an OPERATOR signal — a background caller that sets it
7812
+ * wakes the camera it was trying not to disturb). So on exactly the cameras
7813
+ * this feature is for, "no picture" is a normal state, not an error. A card
7814
+ * saying WAKING with the camera's name is worse than the real frame and much
7815
+ * better than black — and it is what makes the broker servable inside Amazon's
7816
+ * six-second SDP deadline on a cold hub.
7817
+ *
7818
+ * ── Why the source is reported ────────────────────────────────────────────
7819
+ * The two look identical to a consumer and are very different to an operator:
7820
+ * one means the cache is warm, the other means it is cold. The caller logs
7821
+ * which was used, per camera.
7822
+ *
7823
+ * Pure input → bytes. No snapshot fetch, no device lookup, no I/O of its own —
7824
+ * the caller does both and hands the results in.
7825
+ */
7826
+ /**
7827
+ * Compose the picture.
7828
+ *
7829
+ * An overlay that cannot be rendered (a truncated or non-decodable cached
7830
+ * frame) falls through to the generated card rather than leaking an unlabelled
7831
+ * stale frame — the same call the snapshot wrapper's `stateImage` makes, for
7832
+ * the same reason.
7833
+ */
7834
+ async function composePrestreamPicture(input) {
7835
+ const tags = input.deviceId === void 0 ? {} : { tags: { deviceId: input.deviceId } };
7836
+ if (input.cachedJpeg !== null && input.cachedJpeg.length > 0) try {
7837
+ return {
7838
+ jpeg: await renderCourtesyOverlayJpeg(input.cachedJpeg, {
7839
+ deviceName: input.deviceName,
7840
+ reason: "waking"
7841
+ }),
7842
+ source: "cached-snapshot"
7843
+ };
7844
+ } catch (err) {
7845
+ input.logger?.warn("prestream: cached frame could not be treated — using the card", {
7846
+ ...tags,
7847
+ meta: { error: errMsg(err) }
7848
+ });
7849
+ }
7850
+ return {
7851
+ jpeg: await renderCourtesyJpeg({
7852
+ deviceName: input.deviceName,
7853
+ reason: "waking",
7854
+ width: input.width,
7855
+ height: input.height
7856
+ }),
7857
+ source: "generated-card"
7858
+ };
7859
+ }
7860
+ function planPrestream(input) {
7861
+ if (input.sourceUrl === void 0 || !input.sourceUrl.startsWith("lazy:rfc4571:")) return { refusal: "not-lazy" };
7862
+ if (input.width === void 0 || input.height === void 0 || input.width <= 0 || input.height <= 0) return { refusal: "unknown-geometry" };
7863
+ const codec = normaliseCodec$1(input.codec);
7864
+ if (codec === null) return { refusal: "unknown-codec" };
7865
+ return { plan: {
7866
+ width: input.width,
7867
+ height: input.height,
7868
+ codec
7869
+ } };
7870
+ }
7871
+ /**
7872
+ * `null` for a codec nobody has read. NOT a default of h264: a prestream in the
7873
+ * wrong codec is a stream the consumer cannot decode at all, which is worse
7874
+ * than the black screen it replaces.
7875
+ */
7876
+ function normaliseCodec$1(codec) {
7877
+ if (codec === void 0) return null;
7878
+ const c = codec.toLowerCase().replace(/[.\s-]/g, "");
7879
+ if (c === "h264" || c === "avc" || c === "avc1") return "h264";
7880
+ if (c === "h265" || c === "hevc" || c === "hvc1" || c === "hev1") return "h265";
7881
+ return null;
7882
+ }
7883
+ //#endregion
7330
7884
  //#region src/stream-broker/stream-broker/profile-restreamers.ts
7331
7885
  /**
7332
7886
  * Diff the desired profile→source aliasing against the current
@@ -11460,6 +12014,124 @@ function getPlaceholderFrame(kind, codec) {
11460
12014
  return buf;
11461
12015
  }
11462
12016
  //#endregion
12017
+ //#region src/stream-broker/stream-broker/prestream-pump.ts
12018
+ /**
12019
+ * The prestream pump: ONE encode, then a 1 fps re-emit until real media lands.
12020
+ *
12021
+ * ── What it is for ────────────────────────────────────────────────────────
12022
+ * A lazy source is BUILT on demand, and on this hub that build costs 2.2 to
12023
+ * 12.4 s. For all of it the consumer had black — and Amazon allows SIX SECONDS
12024
+ * for an Alexa SDP answer, so a cold battery camera could not answer an Echo at
12025
+ * all. The pump makes the broker servable before the camera is: the camera's
12026
+ * own last picture under a "Waking" treatment, at the camera's real geometry,
12027
+ * replaced by real media the instant the first real packet arrives.
12028
+ *
12029
+ * ── The three rules it exists to hold ─────────────────────────────────────
12030
+ * 1. **Prepare ONCE.** The picture is fetched and encoded a single time per
12031
+ * wake and the bytes are cached here. Re-fetching per tick would put a
12032
+ * snapshot round-trip and a fork+exec on every second of every waking
12033
+ * camera, and would move frames across a process boundary at frame-rate —
12034
+ * D9/D18.
12035
+ * 2. **A late preparation lands nowhere.** The encode is async. If the camera
12036
+ * woke while it was running the pump has already been stopped, and starting
12037
+ * the timer then would paint a still picture over live media.
12038
+ * 3. **It never dials.** The pump has no source, no socket and no demand. It
12039
+ * is started alongside the publisher request, never instead of it.
12040
+ *
12041
+ * Emission is the caller's: {@link PrestreamPumpOptions.emit} is where the
12042
+ * `isPlaceholder: true` packet is built, because that flag belongs to the
12043
+ * broker's packet contract and the containment that reads it lives there.
12044
+ */
12045
+ var DEFAULT_PRESTREAM_INTERVAL_MS = 1e3;
12046
+ var PrestreamPump = class {
12047
+ options;
12048
+ intervalMs;
12049
+ tags;
12050
+ timer;
12051
+ /** True from `start()` until `stop()`, INCLUDING while the encode runs. */
12052
+ active = false;
12053
+ frame = null;
12054
+ startedAt = 0;
12055
+ emitted = 0;
12056
+ constructor(options) {
12057
+ this.options = options;
12058
+ this.intervalMs = options.intervalMs ?? DEFAULT_PRESTREAM_INTERVAL_MS;
12059
+ this.tags = options.deviceId === void 0 ? {} : { tags: { deviceId: options.deviceId } };
12060
+ }
12061
+ /** Whether the window is currently being filled (or about to be). */
12062
+ get running() {
12063
+ return this.active;
12064
+ }
12065
+ /**
12066
+ * Open the window. Idempotent: a second call while the first preparation is
12067
+ * still in flight is a no-op, so a reconnect tick that re-enters the lazy
12068
+ * branch every second does not spawn an encode every second.
12069
+ */
12070
+ start(sourceUrl) {
12071
+ if (this.active) return;
12072
+ this.active = true;
12073
+ this.startedAt = Date.now();
12074
+ this.emitted = 0;
12075
+ this.options.prepare({ sourceUrl }).then((frame) => {
12076
+ if (!this.active) {
12077
+ this.options.logger?.debug("prestream: preparation landed after the window closed", { ...this.tags });
12078
+ return;
12079
+ }
12080
+ if (frame === null) {
12081
+ this.options.logger?.info("prestream: refused — the window goes unfilled", { ...this.tags });
12082
+ this.active = false;
12083
+ return;
12084
+ }
12085
+ this.frame = frame;
12086
+ this.options.logger?.info("prestream: filling the wake window", {
12087
+ ...this.tags,
12088
+ meta: {
12089
+ codec: frame.codec,
12090
+ bytes: frame.annexB.length,
12091
+ intervalMs: this.intervalMs,
12092
+ preparedInMs: Date.now() - this.startedAt
12093
+ }
12094
+ });
12095
+ this.tick();
12096
+ this.timer = setInterval(() => this.tick(), this.intervalMs);
12097
+ }).catch((err) => {
12098
+ this.options.logger?.warn("prestream: preparation failed — the window goes unfilled", {
12099
+ ...this.tags,
12100
+ meta: { error: errMsg(err) }
12101
+ });
12102
+ this.active = false;
12103
+ });
12104
+ }
12105
+ /**
12106
+ * Close the window. Called on the first NON-placeholder packet — the one edge
12107
+ * the prestream is defined by — and on the broker's own teardown paths so a
12108
+ * stopped broker leaves no timer behind.
12109
+ */
12110
+ stop(reason) {
12111
+ if (!this.active) return;
12112
+ this.active = false;
12113
+ if (this.timer) {
12114
+ clearInterval(this.timer);
12115
+ this.timer = void 0;
12116
+ }
12117
+ this.options.logger?.info("prestream: window closed", {
12118
+ ...this.tags,
12119
+ meta: {
12120
+ reason,
12121
+ framesEmitted: this.emitted,
12122
+ heldMs: Date.now() - this.startedAt
12123
+ }
12124
+ });
12125
+ this.frame = null;
12126
+ }
12127
+ tick() {
12128
+ const frame = this.frame;
12129
+ if (!this.active || frame === null) return;
12130
+ this.emitted++;
12131
+ this.options.emit(frame);
12132
+ }
12133
+ };
12134
+ //#endregion
11463
12135
  //#region src/stream-broker/stream-broker/stream-pipe-server.ts
11464
12136
  /**
11465
12137
  * A lightweight TCP server that broadcasts raw encoded video bytes
@@ -11752,6 +12424,23 @@ var StreamBroker = class StreamBroker {
11752
12424
  audioMutedDropLogged = false;
11753
12425
  reconnectTimer;
11754
12426
  placeholderTimer;
12427
+ /**
12428
+ * The prestream pump, armed by the manager via `setPrestreamProvider`.
12429
+ * `undefined` on a broker whose manager supplied none — the pre-prestream
12430
+ * behaviour, unchanged.
12431
+ */
12432
+ prestreamPump;
12433
+ /**
12434
+ * Latched once real media has been seen on this session.
12435
+ *
12436
+ * The lazy branch is re-entered on every materialize poll tick, so stopping
12437
+ * the pump is not enough on its own: the very next tick would re-open a
12438
+ * window that real bytes have already closed, and the consumer would get a
12439
+ * still picture painted over live video every second. Reset exactly where
12440
+ * `firstKeyframeReceived` is — broker stop and suspend — so a battery camera
12441
+ * that goes back to sleep gets its prestream again on the next wake.
12442
+ */
12443
+ prestreamWindowClosed = false;
11755
12444
  reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
11756
12445
  /**
11757
12446
  * Dial rate-limiter (2026-07-23 storm fix): the earliest wall-clock at
@@ -11846,6 +12535,15 @@ var StreamBroker = class StreamBroker {
11846
12535
  */
11847
12536
  materializeRequestedAt = 0;
11848
12537
  /**
12538
+ * The hold a camera's own signal bought. See `wake-hold.ts` — a term of
12539
+ * {@link hasLiveIntent}, so the predicate's owner owns the state: putting it
12540
+ * in the manager would give two places a vote on whether to dial.
12541
+ */
12542
+ wakeHold = NO_HOLD;
12543
+ /** Fires when the hold lapses, so the suspend happens instead of waiting for
12544
+ * an unrelated edge. Re-armed on every extension. */
12545
+ wakeHoldTimer;
12546
+ /**
11849
12547
  * Rolling source-RTP activity watchdog. Distinct from the first-video
11850
12548
  * watchdog: this one ARMS at DIAL time (so a connection that opens the
11851
12549
  * loopback TCP but never delivers media is still detected) and RESETS on
@@ -12145,6 +12843,18 @@ var StreamBroker = class StreamBroker {
12145
12843
  getLastPacketAt() {
12146
12844
  return this.lastPacketAt;
12147
12845
  }
12846
+ /**
12847
+ * Whether a LAZY publisher is being built right now.
12848
+ *
12849
+ * A consumer deciding whether it is worth waiting for this source needs to
12850
+ * tell "cold and idle" from "waking up": the first cannot be waited for at
12851
+ * all (the dial is the demand that starts the wake), the second can.
12852
+ * `getFirstVideoWatchdogStatus` carries the same number and is a TEST SEAM —
12853
+ * this is the production read.
12854
+ */
12855
+ isMaterializing() {
12856
+ return this.materializeRequestedAt > 0;
12857
+ }
12148
12858
  /** Source type of the active stream (rtsp/push/rtmp/placeholder/error). */
12149
12859
  getActiveSourceType() {
12150
12860
  return this.source?.type ?? "unknown";
@@ -12233,6 +12943,38 @@ var StreamBroker = class StreamBroker {
12233
12943
  this.requestSourceRefresh = notify;
12234
12944
  }
12235
12945
  /**
12946
+ * Arm the PRESTREAM — the fill for the window between "someone asked for this
12947
+ * camera" and "the camera is awake".
12948
+ *
12949
+ * Manager-owned, because everything the fill needs is the manager's: the
12950
+ * camera's probed geometry and codec (`planPrestream`), its last cached
12951
+ * snapshot, its name, and the ffmpeg binary. The broker contributes the one
12952
+ * thing it owns — WHEN the window is open — and nothing else.
12953
+ *
12954
+ * A broker with no provider behaves exactly as it did before the prestream
12955
+ * existed: the lazy branch asks the publisher to materialize and the consumer
12956
+ * sees black, which is the outcome every refusal falls back to.
12957
+ */
12958
+ setPrestreamProvider(provider) {
12959
+ this.prestreamPump = new PrestreamPump({
12960
+ prepare: provider.prepare,
12961
+ emit: (frame) => {
12962
+ const now = Date.now();
12963
+ this.pushEncodedPacket({
12964
+ type: "video",
12965
+ data: frame.annexB,
12966
+ pts: now,
12967
+ dts: now,
12968
+ keyframe: true,
12969
+ codec: frame.codec,
12970
+ isPlaceholder: true
12971
+ });
12972
+ },
12973
+ ...this.logger ? { logger: this.logger } : {},
12974
+ ...this.numericDeviceId === void 0 ? {} : { deviceId: this.numericDeviceId }
12975
+ });
12976
+ }
12977
+ /**
12236
12978
  * Fire `requestSourceRefresh` at most once per ~500 ms. The
12237
12979
  * dial-failure path fans into onError + reader.connect().catch; we
12238
12980
  * only want one round-trip to the publisher per real failure.
@@ -12282,7 +13024,7 @@ var StreamBroker = class StreamBroker {
12282
13024
  */
12283
13025
  redial(reason) {
12284
13026
  if (this.manualStop || this.stopping || !this.source) return;
12285
- if (!this.hasDemand()) return;
13027
+ if (!this.hasLiveIntent()) return;
12286
13028
  this.logger?.info("source redial — the camera changed under a live reader", { meta: {
12287
13029
  reason,
12288
13030
  status: this._status
@@ -12298,7 +13040,7 @@ var StreamBroker = class StreamBroker {
12298
13040
  }
12299
13041
  kickReconnect() {
12300
13042
  if (this.manualStop || this.stopping || !this.source) return;
12301
- if (!this.hasDemand()) return;
13043
+ if (!this.hasLiveIntent()) return;
12302
13044
  if (!this.reconnectTimer) return;
12303
13045
  const nowMs = Date.now();
12304
13046
  if (nowMs - this.lastKickAt < INITIAL_RECONNECT_DELAY_MS) return;
@@ -12340,7 +13082,7 @@ var StreamBroker = class StreamBroker {
12340
13082
  } });
12341
13083
  await this.pipeServer.start();
12342
13084
  this.logger?.debug("Pipe server started", { meta: { url: this.pipeServer.getUrl() } });
12343
- if (isLazyDialSource(source.type) && !this.hasDemand()) {
13085
+ if (isLazyDialSource(source.type) && !this.hasLiveIntent()) {
12344
13086
  this._suspended = true;
12345
13087
  this._status = "idle";
12346
13088
  this.logger?.info("Broker registered idle — no demand, source dial deferred");
@@ -12398,6 +13140,8 @@ var StreamBroker = class StreamBroker {
12398
13140
  clearInterval(this.placeholderTimer);
12399
13141
  this.placeholderTimer = void 0;
12400
13142
  }
13143
+ this.prestreamPump?.stop("broker stopped");
13144
+ this.prestreamWindowClosed = false;
12401
13145
  if (this.reconnectTimer) {
12402
13146
  clearTimeout(this.reconnectTimer);
12403
13147
  this.reconnectTimer = void 0;
@@ -12559,14 +13303,98 @@ var StreamBroker = class StreamBroker {
12559
13303
  this.checkDemand();
12560
13304
  }
12561
13305
  /** True when at least one consumer needs the stream alive. */
12562
- hasDemand() {
13306
+ /**
13307
+ * A signal the CAMERA reported about itself.
13308
+ *
13309
+ * Two terms must hold before anything is dialled, and they are the whole
13310
+ * safety of this feature:
13311
+ *
13312
+ * - the caller has already checked the signal is ENABLED for this camera
13313
+ * (the broker manager owns that setting), and
13314
+ * - somebody is actually waiting on the prestream. Waking a camera nobody is
13315
+ * watching is the one thing a battery camera must never do, and a signal
13316
+ * arriving with no consumer is exactly that.
13317
+ *
13318
+ * Returns what it did, so the caller's log line can say it. A hold that is
13319
+ * merely EXTENDED does not re-dial: the camera is already up.
13320
+ */
13321
+ reportWakeSignal(code, holdMs, at) {
13322
+ if (this.rtspRestreamer.getPrestreamSessionCount() === 0) return "no-consumer";
13323
+ const wasLive = holdIsLive(this.wakeHold, at);
13324
+ const { hold, outcome } = absorbSignal({
13325
+ current: this.wakeHold,
13326
+ now: at,
13327
+ holdMs
13328
+ });
13329
+ this.wakeHold = hold;
13330
+ this.logger?.info("wake signal from the camera", {
13331
+ tags: { deviceId: this.numericDeviceId },
13332
+ meta: {
13333
+ brokerId: this.deviceId,
13334
+ code,
13335
+ outcome,
13336
+ signals: hold.signals,
13337
+ untilMs: Math.max(0, hold.until - at),
13338
+ runMs: hold.runStartedAt > 0 ? at - hold.runStartedAt : 0
13339
+ }
13340
+ });
13341
+ this.armWakeHoldTimer(at);
13342
+ if (outcome === "quarantined") return "no-consumer";
13343
+ if (wasLive) return "hold-extended";
13344
+ this.checkDemand();
13345
+ return "woke";
13346
+ }
13347
+ /**
13348
+ * Look at the hold when it lapses, so the camera actually goes back to sleep
13349
+ * instead of waiting for an unrelated edge to notice.
13350
+ */
13351
+ armWakeHoldTimer(now) {
13352
+ if (this.wakeHoldTimer) clearTimeout(this.wakeHoldTimer);
13353
+ const delay = this.wakeHold.until - now;
13354
+ if (delay <= 0) return;
13355
+ this.wakeHoldTimer = setTimeout(() => {
13356
+ this.wakeHoldTimer = void 0;
13357
+ this.logger?.info("wake hold lapsed — back to the prestream picture", {
13358
+ tags: { deviceId: this.numericDeviceId },
13359
+ meta: {
13360
+ brokerId: this.deviceId,
13361
+ signals: this.wakeHold.signals,
13362
+ runMs: this.wakeHold.runStartedAt > 0 ? Date.now() - this.wakeHold.runStartedAt : 0
13363
+ }
13364
+ });
13365
+ this.checkDemand();
13366
+ }, delay);
13367
+ this.wakeHoldTimer.unref?.();
13368
+ }
13369
+ /**
13370
+ * Anything that must be FED, prestream sessions included.
13371
+ *
13372
+ * The only consumer of this is the prestream pump: it says "somebody is
13373
+ * watching, keep the picture going", and says nothing about whether the
13374
+ * camera may be dialled. {@link hasLiveIntent} answers that, and it is the
13375
+ * one every dial path asks.
13376
+ */
13377
+ hasAnyConsumer() {
13378
+ if (this.hasLiveIntent()) return true;
13379
+ return this.rtspRestreamer.getPrestreamSessionCount() > 0;
13380
+ }
13381
+ /**
13382
+ * Consumers that may DIAL. Every term of the former `hasDemand`, with the
13383
+ * RTSP session count filtered to exclude prestream dials, plus the wake hold.
13384
+ *
13385
+ * For a broker with no prestream session attached this is term-for-term what
13386
+ * `hasDemand` was, which is why every existing camera, consumer and test
13387
+ * behaves identically.
13388
+ */
13389
+ hasLiveIntent() {
12563
13390
  if (this.encodedCallbacks.size > 0) return true;
12564
13391
  if (this.rtpVideoCallbacks.size > 0) return true;
12565
13392
  if ((this.frameHandlePlane?.subscriberCount ?? 0) > 0) return true;
12566
13393
  if (this.audioChunkPlane.subscriberCount > 0) return true;
12567
- if (this.rtspRestreamer.getSessionCount() > 0) return true;
13394
+ if (this.rtspRestreamer.getLiveIntentSessionCount() > 0) return true;
12568
13395
  if (this.pipeServer.getClientCount() > 0) return true;
12569
13396
  if (this.prebufferHold !== null) return true;
13397
+ if (holdIsLive(this.wakeHold, Date.now())) return true;
12570
13398
  return false;
12571
13399
  }
12572
13400
  /**
@@ -12682,7 +13510,7 @@ var StreamBroker = class StreamBroker {
12682
13510
  checkDemand() {
12683
13511
  if (this.manualStop || this.stopping) return;
12684
13512
  this.reconcileAudioConsumer();
12685
- if (this.hasDemand()) {
13513
+ if (this.hasLiveIntent()) {
12686
13514
  if (!this.demandActive) {
12687
13515
  this.demandActive = true;
12688
13516
  this.demandObserver?.(true);
@@ -12706,9 +13534,19 @@ var StreamBroker = class StreamBroker {
12706
13534
  } else if (!this._suspended) {
12707
13535
  if (!this.suspendTimer) this.suspendTimer = setTimeout(() => {
12708
13536
  this.suspendTimer = void 0;
12709
- if (!this.hasDemand() && !this.manualStop && !this.stopping) this.suspend();
13537
+ if (!this.hasLiveIntent() && !this.manualStop && !this.stopping) this.suspend();
12710
13538
  }, SUSPEND_GRACE_MS);
12711
13539
  }
13540
+ this.prestreamWanted = this.hasAnyConsumer() && !this.hasLiveIntent();
13541
+ }
13542
+ /**
13543
+ * Whether a prestream picture should be flowing right now: somebody is
13544
+ * watching and the camera is not up. Read by the pump.
13545
+ */
13546
+ prestreamWanted = false;
13547
+ /** Whether the picture should be flowing — the pump's one question. */
13548
+ wantsPrestream() {
13549
+ return this.prestreamWanted;
12712
13550
  }
12713
13551
  /** Suspend the stream: kill ffmpeg/native client, clear buffers, go idle. Source is preserved for resume. */
12714
13552
  suspend() {
@@ -12733,6 +13571,8 @@ var StreamBroker = class StreamBroker {
12733
13571
  clearInterval(this.placeholderTimer);
12734
13572
  this.placeholderTimer = void 0;
12735
13573
  }
13574
+ this.prestreamPump?.stop("broker suspended");
13575
+ this.prestreamWindowClosed = false;
12736
13576
  this.preBuffer.clear();
12737
13577
  this.decoderSeedBuffer.clear();
12738
13578
  this.clipBuffer?.clear();
@@ -12816,6 +13656,10 @@ var StreamBroker = class StreamBroker {
12816
13656
  }
12817
13657
  pushEncodedPacket(packet) {
12818
13658
  if (this.stopping) return;
13659
+ if (packet.isPlaceholder !== true && !this.prestreamWindowClosed) {
13660
+ this.prestreamWindowClosed = true;
13661
+ this.prestreamPump?.stop("first real packet");
13662
+ }
12819
13663
  if (this.source?.type === "push") {
12820
13664
  this.stopErrorPlaceholder();
12821
13665
  if (this._status === "error") this._status = "streaming";
@@ -12857,17 +13701,17 @@ var StreamBroker = class StreamBroker {
12857
13701
  }
12858
13702
  }
12859
13703
  if (packet.type === "video" && !this.isRtpSource()) this.preBuffer.push(packet);
12860
- if (packet.type === "video" && this.clipBuffer) this.clipBuffer.push(packet);
13704
+ if (packet.type === "video" && this.clipBuffer && packet.isPlaceholder !== true) this.clipBuffer.push(packet);
12861
13705
  this.fanOutEncoded(packet);
12862
13706
  if (packet.type === "video") {
12863
13707
  this.pipeServer.broadcast(packet.data);
12864
13708
  if (!this.rtspRestreamer.rtpPassthroughMode) this.rtspRestreamer.pushPacket(packet);
12865
- if (!this.firstKeyframeReceived && packet.keyframe) {
13709
+ if (!this.firstKeyframeReceived && packet.keyframe && packet.isPlaceholder !== true) {
12866
13710
  this.firstKeyframeReceived = true;
12867
13711
  this.logger?.info("First keyframe received — decoder can start");
12868
13712
  }
12869
13713
  }
12870
- if (packet.type === "video" && this.firstKeyframeReceived) {
13714
+ if (packet.type === "video" && this.firstKeyframeReceived && packet.isPlaceholder !== true) {
12871
13715
  const plane = this.frameHandlePlane;
12872
13716
  if (plane) {
12873
13717
  const decoderPacket = this.decoderPacketWithParamSets(packet);
@@ -14431,7 +15275,7 @@ var StreamBroker = class StreamBroker {
14431
15275
  }
14432
15276
  scheduleReconnect() {
14433
15277
  if (this.manualStop || !this.source) return;
14434
- if (!this.hasDemand()) {
15278
+ if (!this.hasLiveIntent()) {
14435
15279
  this._suspended = true;
14436
15280
  this._status = "idle";
14437
15281
  this.stopErrorPlaceholder();
@@ -14543,6 +15387,7 @@ var StreamBroker = class StreamBroker {
14543
15387
  });
14544
15388
  this._status = "error";
14545
15389
  this.notifySourceRefresh();
15390
+ if (!this.prestreamWindowClosed) this.prestreamPump?.start(source.url);
14546
15391
  this.scheduleReconnect();
14547
15392
  return;
14548
15393
  }
@@ -15272,7 +16117,23 @@ var DeviceOverrideSchema = object({
15272
16117
  derivedStreams: record(string(), DerivedStreamSchema).optional(),
15273
16118
  hwaccel: record(string(), FfmpegHwAccelSettingSchema).optional(),
15274
16119
  sourceParser: record(string(), SourceParserSettingSchema).optional(),
15275
- sourceParserArgs: record(string(), SourceParserArgsEntrySchema).optional()
16120
+ sourceParserArgs: record(string(), SourceParserArgsEntrySchema).optional(),
16121
+ /**
16122
+ * The on-demand PRESTREAM, per camera.
16123
+ *
16124
+ * Absent means the operator has never touched it, which is NOT the same as
16125
+ * off: `enabledSignals` absent means "every signal this camera recommends",
16126
+ * so a Reolink works the moment a prestream URL is dialled without an
16127
+ * operator hunting for a checkbox. An empty ARRAY is a deliberate "none" and
16128
+ * is honoured — the two are different answers and the shape keeps them
16129
+ * apart.
16130
+ */
16131
+ prestream: object({
16132
+ /** Signal codes allowed to wake this camera. Absent ⇒ the recommended set. */
16133
+ enabledSignals: array(string()).optional(),
16134
+ /** How long the camera stays up after the LAST signal. Absent ⇒ default. */
16135
+ holdMs: number().int().min(1e3).max(3e5).optional()
16136
+ }).optional()
15276
16137
  });
15277
16138
  /**
15278
16139
  * Read `audioEnabled` off a `privacyMask.getStatus` payload (D83 reconcile).
@@ -15416,6 +16277,12 @@ var StreamBrokerManager = class StreamBrokerManager {
15416
16277
  profileRestreamerBindings = /* @__PURE__ */ new Map();
15417
16278
  /** Per-device published cam-stream registry. */
15418
16279
  cameraStreams = /* @__PURE__ */ new Map();
16280
+ /**
16281
+ * The encoded prestream frame per broker, with the digest of the picture it
16282
+ * was made from. One entry is one still image — kilobytes — and it is
16283
+ * dropped with the broker in `releaseBroker`.
16284
+ */
16285
+ prestreamFrames = /* @__PURE__ */ new Map();
15419
16286
  /** Monotonic insertion counter per device — feeds `CameraStreamInternal.order`. */
15420
16287
  streamOrderSeq = /* @__PURE__ */ new Map();
15421
16288
  /** Persisted operator intent: which cam stream serves which profile. */
@@ -15767,7 +16634,10 @@ var StreamBrokerManager = class StreamBrokerManager {
15767
16634
  sourceHasMedia: (brokerId) => {
15768
16635
  const broker = this.brokers.get(brokerId);
15769
16636
  if (broker === void 0) return null;
15770
- return broker.getLastPacketAt() > 0;
16637
+ return sourceWaitState({
16638
+ hasMedia: broker.getLastPacketAt() > 0,
16639
+ materializing: broker.isMaterializing()
16640
+ });
15771
16641
  },
15772
16642
  ffmpegConfig: () => this.ffmpegConfig,
15773
16643
  ...this.costRegistry === null ? {} : { costRegistry: this.costRegistry },
@@ -16303,6 +17173,150 @@ var StreamBrokerManager = class StreamBrokerManager {
16303
17173
  * firehose so we don't filter the entire device-state stream
16304
17174
  * client-side and we get a typed payload.
16305
17175
  */
17176
+ /**
17177
+ * Build the per-broker PRESTREAM provider — the fill for the window between
17178
+ * "someone asked for this camera" and "the camera is awake".
17179
+ *
17180
+ * On this hub that window is 2.2-12.4 s for a lazy source, and it is not
17181
+ * merely ugly: Amazon allows SIX SECONDS for an Alexa SDP answer, so a cold
17182
+ * battery camera could not answer an Echo at all.
17183
+ *
17184
+ * Four facts are needed and all four are the manager's, which is why the
17185
+ * provider is a closure here rather than anything the broker knows about:
17186
+ * the probed geometry and codec ({@link planPrestream}), the camera's last
17187
+ * cached snapshot, its name, and the ffmpeg binary path.
17188
+ *
17189
+ * `null` is a REFUSAL, named in the log. Refusing is cheap — the consumer
17190
+ * gets the black screen it got before the prestream existed — and the
17191
+ * alternative for an unknown geometry is the decoder freeze the whole
17192
+ * synthetic-frame path is shaped around.
17193
+ */
17194
+ buildPrestreamProvider(deviceId, camStreamId, brokerId, logger) {
17195
+ return { prepare: async ({ sourceUrl }) => {
17196
+ const gate = prestreamEligible({
17197
+ batteryOperated: await this.isBatteryOperated(deviceId),
17198
+ declaresSignals: (await this.declaredSignalCodes(deviceId)).length > 0
17199
+ });
17200
+ if (!gate.eligible) {
17201
+ logger.info("prestream: not offered for this camera", {
17202
+ tags: { deviceId },
17203
+ meta: {
17204
+ brokerId,
17205
+ reason: gate.reason
17206
+ }
17207
+ });
17208
+ return null;
17209
+ }
17210
+ const stream = this.cameraStreams.get(deviceId)?.get(camStreamId);
17211
+ const codec = this.lastProbedByBroker.get(brokerId)?.codec ?? stream?.codec;
17212
+ const planned = planPrestream({
17213
+ sourceUrl,
17214
+ width: stream?.resolution?.width,
17215
+ height: stream?.resolution?.height,
17216
+ codec
17217
+ });
17218
+ if ("refusal" in planned) {
17219
+ logger.info("prestream: no fill for this camera", {
17220
+ tags: { deviceId },
17221
+ meta: {
17222
+ refusal: planned.refusal,
17223
+ camStreamId,
17224
+ width: stream?.resolution?.width ?? null,
17225
+ height: stream?.resolution?.height ?? null,
17226
+ codec: codec ?? null
17227
+ }
17228
+ });
17229
+ return null;
17230
+ }
17231
+ return await this.preparePrestreamFrame(deviceId, brokerId, planned.plan, logger);
17232
+ } };
17233
+ }
17234
+ /**
17235
+ * Fetch the picture, compose the treatment, encode ONCE and cache the bytes.
17236
+ *
17237
+ * The cache is keyed on the picture's own digest plus the geometry, so a
17238
+ * second wake of the same camera re-uses the frame and spawns nothing: the
17239
+ * picture changes at most hourly, and a fork+exec per wake per sleeping
17240
+ * camera is the cost this shape exists to avoid.
17241
+ */
17242
+ async preparePrestreamFrame(deviceId, brokerId, plan, logger) {
17243
+ if (!this.api) {
17244
+ logger.info("prestream: no api access — the window goes unfilled", { tags: { deviceId } });
17245
+ return null;
17246
+ }
17247
+ const api = this.api;
17248
+ let cachedJpeg = null;
17249
+ try {
17250
+ const image = await api.snapshot.getSnapshot.query({ deviceId });
17251
+ if (image) cachedJpeg = Buffer.from(image.base64, "base64");
17252
+ } catch (err) {
17253
+ logger.debug("prestream: cached snapshot read failed — falling back to the card", {
17254
+ tags: { deviceId },
17255
+ meta: { error: errMsg(err) }
17256
+ });
17257
+ }
17258
+ let deviceName = `#${String(deviceId)}`;
17259
+ try {
17260
+ const device = await api.deviceManager.getDevice.query({ deviceId });
17261
+ if (device?.name) deviceName = device.name;
17262
+ } catch (err) {
17263
+ logger.debug("prestream: device name read failed — using the numeric id", {
17264
+ tags: { deviceId },
17265
+ meta: { error: errMsg(err) }
17266
+ });
17267
+ }
17268
+ const key = `${createHash("sha1").update(cachedJpeg ?? Buffer.alloc(0)).update(deviceName).digest("hex")}:${String(plan.width)}x${String(plan.height)}:${plan.codec}`;
17269
+ const cached = this.prestreamFrames.get(brokerId);
17270
+ if (cached && cached.key === key) {
17271
+ logger.debug("prestream: re-using the encoded frame", {
17272
+ tags: { deviceId },
17273
+ meta: { bytes: cached.frame.annexB.length }
17274
+ });
17275
+ return cached.frame;
17276
+ }
17277
+ const picture = await composePrestreamPicture({
17278
+ cachedJpeg,
17279
+ deviceName,
17280
+ width: plan.width,
17281
+ height: plan.height,
17282
+ logger,
17283
+ deviceId
17284
+ });
17285
+ logger.info("prestream: picture composed", {
17286
+ tags: { deviceId },
17287
+ meta: {
17288
+ source: picture.source,
17289
+ width: plan.width,
17290
+ height: plan.height,
17291
+ codec: plan.codec
17292
+ }
17293
+ });
17294
+ let annexB;
17295
+ try {
17296
+ annexB = await encodePrestreamIdr({
17297
+ ffmpegPath: this.ffmpegConfig.binaryPath,
17298
+ plan,
17299
+ jpeg: picture.jpeg,
17300
+ logger,
17301
+ deviceId
17302
+ });
17303
+ } catch (err) {
17304
+ logger.warn("prestream: encode failed — the window goes unfilled", {
17305
+ tags: { deviceId },
17306
+ meta: { error: errMsg(err) }
17307
+ });
17308
+ return null;
17309
+ }
17310
+ const frame = {
17311
+ annexB,
17312
+ codec: plan.codec
17313
+ };
17314
+ this.prestreamFrames.set(brokerId, {
17315
+ key,
17316
+ frame
17317
+ });
17318
+ return frame;
17319
+ }
16306
17320
  subscribePlaceholderStateSources(bus) {
16307
17321
  bus.subscribe({ category: EventCategory.BatteryOnStatusChanged }, (event) => {
16308
17322
  const data = event.data;
@@ -16457,11 +17471,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16457
17471
  if (!api) return;
16458
17472
  await this.batteryWakeOnPlay.run(deviceId, {
16459
17473
  logger: this.logger,
16460
- isBatteryDevice: async (id) => {
16461
- const dev = await api.deviceManager.getDevice.query({ deviceId: id }).catch(() => null);
16462
- if (!dev) return null;
16463
- return dev.features.includes(DeviceFeature.BatteryOperated);
16464
- },
17474
+ isBatteryDevice: (id) => this.isBatteryOperated(id),
16465
17475
  readBatteryFacts: async (id) => {
16466
17476
  const slice = await api.deviceState.getCapSlice.query({
16467
17477
  deviceId: id,
@@ -17994,6 +19004,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17994
19004
  const parsed = this.parseBrokerId(brokerId);
17995
19005
  if (!parsed) continue;
17996
19006
  const { deviceId, camStreamId } = parsed;
19007
+ if (!healthEventIsOperatorFacing(camStreamId)) continue;
17997
19008
  const lastPacketAt = broker.getLastPacketAt();
17998
19009
  const wasHealthy = this.streamHealthByBroker.get(brokerId);
17999
19010
  const isHealthy = lastPacketAt > 0 && now - lastPacketAt <= STREAM_STALE_TIMEOUT_MS;
@@ -18284,6 +19295,113 @@ var StreamBrokerManager = class StreamBrokerManager {
18284
19295
  * falls back to the global `ffmpeg.hwAccel`). Read by the transcode pipeline
18285
19296
  * per spawn; affects ONLY the transcode paths.
18286
19297
  */
19298
+ /**
19299
+ * A device reporting one of its own signals — the cap method the provider
19300
+ * calls. See `streamBroker.reportStreamSignal` for the contract.
19301
+ *
19302
+ * Everything that could refuse says WHY, because this is the path that
19303
+ * decides whether a battery camera wakes, and a silent refusal here is
19304
+ * indistinguishable from a signal that never arrived.
19305
+ */
19306
+ async reportStreamSignal(input) {
19307
+ const at = input.at ?? Date.now();
19308
+ const declared = await this.declaredSignalCodes(input.deviceId);
19309
+ if (!declared.includes(input.code)) {
19310
+ this.logger.warn("wake signal REFUSED — this camera never declared that code", {
19311
+ tags: { deviceId: input.deviceId },
19312
+ meta: {
19313
+ code: input.code,
19314
+ declared
19315
+ }
19316
+ });
19317
+ return {
19318
+ accepted: false,
19319
+ reason: "unknown-code"
19320
+ };
19321
+ }
19322
+ const settings = this.deviceOverrides.get(input.deviceId)?.prestream;
19323
+ if (!(settings?.enabledSignals ?? declared).includes(input.code)) return {
19324
+ accepted: false,
19325
+ reason: "not-enabled"
19326
+ };
19327
+ const holdMs = settings?.holdMs ?? 15e3;
19328
+ let outcome = "no-consumer";
19329
+ for (const [brokerId, broker] of this.brokers) {
19330
+ if (this.parseBrokerId(brokerId)?.deviceId !== input.deviceId) continue;
19331
+ const result = broker.reportWakeSignal(input.code, holdMs, at);
19332
+ if (result === "woke") outcome = "woke";
19333
+ else if (result === "hold-extended" && outcome !== "woke") outcome = "hold-extended";
19334
+ }
19335
+ return {
19336
+ accepted: outcome !== "no-consumer",
19337
+ reason: outcome
19338
+ };
19339
+ }
19340
+ /**
19341
+ * What this camera says it can emit. `[]` when it declares the cap and lists
19342
+ * nothing, and `[]` when it does not declare the cap at all — both mean no
19343
+ * signal of that name is acceptable, which is the same refusal.
19344
+ */
19345
+ /**
19346
+ * The signals a camera declares, with their labels — what the form renders.
19347
+ * `[]` on any failure, which the caller reads as "not eligible": a form built
19348
+ * from a list we could not read would offer wakes nobody confirmed.
19349
+ */
19350
+ async declaredSignalList(deviceId) {
19351
+ const read = this.deviceSignalDetailReader;
19352
+ if (read === null) return [];
19353
+ try {
19354
+ return await read(deviceId);
19355
+ } catch {
19356
+ return [];
19357
+ }
19358
+ }
19359
+ async declaredSignalCodes(deviceId) {
19360
+ const read = this.deviceSignalReader;
19361
+ if (read === null) return [];
19362
+ try {
19363
+ return await read(deviceId);
19364
+ } catch {
19365
+ return [];
19366
+ }
19367
+ }
19368
+ /**
19369
+ * How the manager asks a DEVICE what signals it declares. Injected by the
19370
+ * host addon, which owns the device proxy. `null` until it is.
19371
+ */
19372
+ deviceSignalReader = null;
19373
+ setDeviceSignalReader(read) {
19374
+ this.deviceSignalReader = read;
19375
+ }
19376
+ /** The same read, with labels — the form needs them, the wake decision does
19377
+ * not. Injected by the host addon for the same reason. */
19378
+ deviceSignalDetailReader = null;
19379
+ setDeviceSignalDetailReader(read) {
19380
+ this.deviceSignalDetailReader = read;
19381
+ }
19382
+ /**
19383
+ * Whether this device runs on a battery.
19384
+ *
19385
+ * The DEVICE REGISTRY, never the published cam-streams: a sleeping camera has
19386
+ * none — which is precisely when this question gets asked — and
19387
+ * `deviceHasFeature` reads the stream map, so it answers `false` for every
19388
+ * sleeping battery camera. That reasoning was written for the wake-on-play
19389
+ * path and belongs to the question, not to one caller of it.
19390
+ *
19391
+ * `null` when nobody could say, and unknown is refused by every caller rather
19392
+ * than guessed: guessing yes puts a mains camera in a wake-hold loop, and
19393
+ * guessing no silently disables the feature on the cameras it exists for.
19394
+ */
19395
+ async isBatteryOperated(deviceId) {
19396
+ if (!this.api) return null;
19397
+ try {
19398
+ const features = (await this.api.deviceManager.getDevice.query({ deviceId }))?.features;
19399
+ if (!Array.isArray(features)) return null;
19400
+ return features.includes(DeviceFeature.BatteryOperated);
19401
+ } catch {
19402
+ return null;
19403
+ }
19404
+ }
18287
19405
  getStreamHwAccelOverride(deviceId, camStreamId) {
18288
19406
  return this.deviceOverrides.get(deviceId)?.hwaccel?.[camStreamId] ?? null;
18289
19407
  }
@@ -18382,6 +19500,12 @@ var StreamBrokerManager = class StreamBrokerManager {
18382
19500
  value: setting,
18383
19501
  label: hwAccelOptionLabel(setting)
18384
19502
  }));
19503
+ const declaredSignals = await this.declaredSignalList(deviceId);
19504
+ const prestreamGate = prestreamEligible({
19505
+ batteryOperated: await this.isBatteryOperated(deviceId),
19506
+ declaresSignals: declaredSignals.length > 0
19507
+ });
19508
+ const prestreamOffered = prestreamGate.eligible;
18385
19509
  const streamTabs = streams.filter((s) => !s.camStreamId.startsWith("derived:")).map((stream) => {
18386
19510
  const camStreamId = stream.camStreamId;
18387
19511
  const brokerId = brokerIdFor(deviceId, camStreamId);
@@ -18481,6 +19605,20 @@ var StreamBrokerManager = class StreamBrokerManager {
18481
19605
  tooltip: "Copy muted URL to clipboard"
18482
19606
  }]
18483
19607
  },
19608
+ ...prestreamOffered ? [{
19609
+ type: "text",
19610
+ key: `rtspPrestreamUrl:${camStreamId}`,
19611
+ label: "RTSP URL (prestream)",
19612
+ 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.",
19613
+ readonlyField: true,
19614
+ span: 2,
19615
+ value: rtspEntry?.url ? `${rtspEntry.url}/prestream` : "inactive",
19616
+ actions: [{
19617
+ action: "copy-value",
19618
+ icon: "copy",
19619
+ tooltip: "Copy prestream URL to clipboard"
19620
+ }]
19621
+ }] : [],
18484
19622
  {
18485
19623
  type: "boolean",
18486
19624
  style: "switch",
@@ -18648,7 +19786,7 @@ var StreamBrokerManager = class StreamBrokerManager {
18648
19786
  }
18649
19787
  ]
18650
19788
  }));
18651
- return { sections: [{
19789
+ const streamBrokerSection = {
18652
19790
  id: "stream-broker-streams",
18653
19791
  title: "",
18654
19792
  tab: "stream-broker",
@@ -18665,7 +19803,37 @@ var StreamBrokerManager = class StreamBrokerManager {
18665
19803
  ...derivedTabs
18666
19804
  ]
18667
19805
  }]
18668
- }, {
19806
+ };
19807
+ const prestreamCfg = override?.prestream;
19808
+ const prestreamSections = prestreamGate.eligible ? [{
19809
+ id: "stream-broker-prestream",
19810
+ title: "Wake signals",
19811
+ tab: "stream-broker",
19812
+ location: "top-tab",
19813
+ order: 50,
19814
+ 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.",
19815
+ fields: [...declaredSignals.map((signal) => ({
19816
+ type: "boolean",
19817
+ style: "switch",
19818
+ key: `prestreamSignal:${signal.code}`,
19819
+ label: signal.label,
19820
+ description: signal.recommended ? "Recommended by this camera’s driver." : "Offered by this camera’s driver.",
19821
+ default: signal.recommended,
19822
+ value: prestreamCfg?.enabledSignals === void 0 ? true : prestreamCfg.enabledSignals.includes(signal.code)
19823
+ })), {
19824
+ type: "number",
19825
+ key: "prestreamHoldMs",
19826
+ label: "Stay awake after the last signal",
19827
+ 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.",
19828
+ min: 1e3,
19829
+ max: 3e5,
19830
+ step: 1e3,
19831
+ unit: "ms",
19832
+ default: DEFAULT_WAKE_HOLD_MS,
19833
+ value: prestreamCfg?.holdMs ?? 15e3
19834
+ }]
19835
+ }] : [];
19836
+ const diagnosticsSection = {
18669
19837
  id: "stream-broker-diagnostics",
18670
19838
  title: "Diagnostics",
18671
19839
  tab: "stream-broker",
@@ -18688,7 +19856,12 @@ var StreamBrokerManager = class StreamBrokerManager {
18688
19856
  default: false,
18689
19857
  value: override?.webrtcDebug === true
18690
19858
  }]
18691
- }] };
19859
+ };
19860
+ return { sections: [
19861
+ streamBrokerSection,
19862
+ ...prestreamSections,
19863
+ diagnosticsSection
19864
+ ] };
18692
19865
  }
18693
19866
  async getDeviceLiveContribution(_input) {
18694
19867
  return null;
@@ -18800,6 +19973,31 @@ var StreamBrokerManager = class StreamBrokerManager {
18800
19973
  nextOverride.sourceParserArgs = spa;
18801
19974
  continue;
18802
19975
  }
19976
+ if (fieldKey.startsWith("prestreamSignal:")) {
19977
+ overrideDirty = true;
19978
+ const next = nextEnabledSignals({
19979
+ declared: await this.declaredSignalCodes(deviceId),
19980
+ stored: nextOverride.prestream?.enabledSignals,
19981
+ code: fieldKey.slice(16),
19982
+ enabled: value === null || value === void 0 ? true : Boolean(value)
19983
+ });
19984
+ nextOverride.prestream = {
19985
+ ...nextOverride.prestream,
19986
+ enabledSignals: [...next]
19987
+ };
19988
+ continue;
19989
+ }
19990
+ if (fieldKey === "prestreamHoldMs") {
19991
+ overrideDirty = true;
19992
+ const raw = typeof value === "number" ? value : Number(value);
19993
+ if (!Number.isFinite(raw)) continue;
19994
+ const clamped = Math.min(3e5, Math.max(1e3, Math.round(raw)));
19995
+ nextOverride.prestream = {
19996
+ ...nextOverride.prestream,
19997
+ holdMs: clamped
19998
+ };
19999
+ continue;
20000
+ }
18803
20001
  if (fieldKey === "streamingDebug") {
18804
20002
  overrideDirty = true;
18805
20003
  const next = value === null || value === void 0 ? void 0 : Boolean(value);
@@ -19451,6 +20649,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19451
20649
  broker.setRequestSourceRefresh(() => {
19452
20650
  this.emitRequestStreamSourceRefresh(deviceId, camStreamId, brokerId);
19453
20651
  });
20652
+ broker.setPrestreamProvider(this.buildPrestreamProvider(deviceId, camStreamId, brokerId, brokerLogger));
19454
20653
  try {
19455
20654
  await broker.start(source);
19456
20655
  } catch (err) {
@@ -19498,6 +20697,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19498
20697
  this.streamOfflineReasonByBroker.delete(brokerId);
19499
20698
  await broker.stop();
19500
20699
  this.brokers.delete(brokerId);
20700
+ this.prestreamFrames.delete(brokerId);
19501
20701
  this.sweepFrameSubscriptions(brokerId);
19502
20702
  this.rtspProvider.persistTokens();
19503
20703
  this.syncProfileRestreamers(deviceId);
@@ -19964,6 +21164,28 @@ var WEBRTC_FALLBACK_PROFILE = {
19964
21164
  },
19965
21165
  audio: "passthrough"
19966
21166
  };
21167
+ function shouldWriteLossLine(input) {
21168
+ if (input.lastWritten === null) return {
21169
+ write: true,
21170
+ reason: "first"
21171
+ };
21172
+ if (input.sample.hasReceiverReport !== input.lastWritten.hasReceiverReport) return {
21173
+ write: true,
21174
+ reason: "reporting-changed"
21175
+ };
21176
+ if (Math.abs(input.sample.lossPct - input.lastWritten.lossPct) >= 2) return {
21177
+ write: true,
21178
+ reason: "loss-moved"
21179
+ };
21180
+ if (input.ticksSinceWrite >= 10) return {
21181
+ write: true,
21182
+ reason: "heartbeat"
21183
+ };
21184
+ return {
21185
+ write: false,
21186
+ reason: "quiet"
21187
+ };
21188
+ }
19967
21189
  //#endregion
19968
21190
  //#region src/stream-broker/webrtc/mdns-resolve.ts
19969
21191
  /**
@@ -20939,6 +22161,14 @@ function selectAudioClock(mimeType) {
20939
22161
  }
20940
22162
  //#endregion
20941
22163
  //#region src/stream-broker/webrtc/session.ts
22164
+ /**
22165
+ * Adaptive WebRTC session — extends the base session pattern with:
22166
+ * - RTCP Receiver Report monitoring (packet loss, jitter, RTT)
22167
+ * - Source replacement (replaceTrack for seamless quality switching)
22168
+ * - Stats emission for the AdaptiveController
22169
+ *
22170
+ * Uses werift (optional peer dependency) for server-side WebRTC.
22171
+ */
20942
22172
  var _werift;
20943
22173
  async function loadWerift() {
20944
22174
  if (_werift) return _werift;
@@ -21245,6 +22475,10 @@ var AdaptiveSession = class AdaptiveSession {
21245
22475
  /** Log the camera's actual H.264 profile-level-id once per session. */
21246
22476
  profileLogged = false;
21247
22477
  onStats;
22478
+ consumerKind;
22479
+ /** The last loss sample actually WRITTEN, and how many ticks ago. */
22480
+ lastLossWritten = null;
22481
+ lossTicksSinceWrite = 0;
21248
22482
  debug;
21249
22483
  sourceCodec;
21250
22484
  /**
@@ -21554,6 +22788,7 @@ var AdaptiveSession = class AdaptiveSession {
21554
22788
  this.intercom = options.intercom;
21555
22789
  this.iceConfig = options.iceConfig;
21556
22790
  this.onStats = options.onStats;
22791
+ this.consumerKind = options.consumerKind ?? "unknown";
21557
22792
  this.debug = options.debug ?? false;
21558
22793
  this.sourceCodec = options.sourceCodec ?? "H264";
21559
22794
  this.transcodeToBaseline = options.transcodeToBaseline ?? false;
@@ -23556,6 +24791,37 @@ var AdaptiveSession = class AdaptiveSession {
23556
24791
  selectedRemoteType: nominated?.remoteType ?? "none",
23557
24792
  selectedRemoteAddr: nominated?.remoteAddr ?? "none"
23558
24793
  } });
24794
+ const rembRaw = this.videoSender?.receiverEstimatedMaxBitrate;
24795
+ const rembBps = typeof rembRaw === "bigint" && rembRaw > 0n ? Number(rembRaw) : -1;
24796
+ const sample = {
24797
+ hasReceiverReport: report !== void 0,
24798
+ lossPct: report ? Math.round(fractionLost / 256 * 100) : -1
24799
+ };
24800
+ this.lossTicksSinceWrite += 1;
24801
+ const verdict = shouldWriteLossLine({
24802
+ sample,
24803
+ lastWritten: this.lastLossWritten,
24804
+ ticksSinceWrite: this.lossTicksSinceWrite
24805
+ });
24806
+ if (verdict.write) {
24807
+ this.lastLossWritten = sample;
24808
+ this.lossTicksSinceWrite = 0;
24809
+ this.logger.info("webrtc loss", {
24810
+ tags: { deviceId: this.deviceId },
24811
+ meta: {
24812
+ sessionId: this.sessionId,
24813
+ consumerKind: this.consumerKind,
24814
+ why: verdict.reason,
24815
+ hasReceiverReport: sample.hasReceiverReport,
24816
+ rembBps,
24817
+ lossPct: sample.lossPct,
24818
+ packetsLost,
24819
+ jitterMs: report ? Math.round(jitter / 90) : -1,
24820
+ rttMs: report ? Math.round(rtt * 1e3) : -1,
24821
+ rtpPacketsSent: this.rtpPacketsSent
24822
+ }
24823
+ });
24824
+ }
23559
24825
  if (report && this.onStats) this.onStats({
23560
24826
  sessionId: this.sessionId,
23561
24827
  packetLoss: fractionLost / 256,
@@ -28784,7 +30050,8 @@ var BrokerWebrtcServer = class {
28784
30050
  logger: sessionLogger,
28785
30051
  deviceId,
28786
30052
  rtpBootstrap: useRtpRepacketizer ? () => broker.getRtpPreBuffer() : void 0,
28787
- debug: opts.streamingDebug === true
30053
+ debug: opts.streamingDebug === true,
30054
+ consumerKind: baseAttribution.kind
28788
30055
  });
28789
30056
  const seedFromSdp = (ps) => {
28790
30057
  if (sessionCodec === "H265") session.seedH265CodecInfoFromSdp(ps);
@@ -29167,7 +30434,11 @@ var BrokerWebrtcServer = class {
29167
30434
  /** Live signaling state for a session — used by the webrtc-session
29168
30435
  * provider's getSessionState. Returns a null pending when unknown. */
29169
30436
  getSessionRenegotiationState(sessionId) {
29170
- return { pendingRenegotiation: this.sessions.get(sessionId)?.session.pendingRenegotiation ?? null };
30437
+ const entry = this.sessions.get(sessionId);
30438
+ return {
30439
+ pendingRenegotiation: entry?.session.pendingRenegotiation ?? null,
30440
+ alive: entry !== void 0
30441
+ };
29171
30442
  }
29172
30443
  /**
29173
30444
  * Wire an {@link AdaptiveController} to a freshly-created adaptive session.
@@ -29577,12 +30848,15 @@ var CameraStreamsProvider = class {
29577
30848
  const accept = requirements.acceptCodecs ?? [];
29578
30849
  const minHeight = requirements.minHeight ?? 0;
29579
30850
  const minWidth = requirements.minWidth ?? 0;
30851
+ const maxHeight = requirements.maxHeight ?? Number.POSITIVE_INFINITY;
30852
+ const allowTranscode = requirements.allowTranscode === true;
29580
30853
  const candidates = streams.filter((s) => {
29581
30854
  if (!isSelectable(s)) return false;
29582
30855
  if (excludeDerived && s.camStreamId.startsWith("derived:")) return false;
29583
- if (accept.length > 0 && !codecMatches(s.codec, accept)) return false;
30856
+ if (!allowTranscode && accept.length > 0 && !codecMatches(s.codec, accept)) return false;
29584
30857
  if ((s.resolution?.height ?? 0) < minHeight) return false;
29585
30858
  if ((s.resolution?.width ?? 0) < minWidth) return false;
30859
+ if ((s.resolution?.height ?? 0) > maxHeight) return false;
29586
30860
  return true;
29587
30861
  });
29588
30862
  if (candidates.length === 0) {
@@ -29627,24 +30901,35 @@ var CameraStreamsProvider = class {
29627
30901
  for (let i = 0; i < preferredProviders.length; i += 1) if (camStreamId.startsWith(preferredProviders[i])) return i;
29628
30902
  return preferredProviders.length;
29629
30903
  };
29630
- candidates.sort((a, b) => {
29631
- const ra = providerRank(a.camStreamId);
29632
- const rb = providerRank(b.camStreamId);
29633
- if (ra !== rb) return ra - rb;
29634
- const ha = a.resolution?.height ?? 0;
29635
- const hb = b.resolution?.height ?? 0;
29636
- return resolutionPreference === "highest" ? hb - ha : ha - hb;
29637
- });
29638
- const winner = candidates[0];
30904
+ const ranked = rankStreams(candidates.map((s) => ({
30905
+ camStreamId: s.camStreamId,
30906
+ ...s.codec === void 0 ? {} : { codec: s.codec },
30907
+ ...s.resolution ? {
30908
+ height: s.resolution.height,
30909
+ width: s.resolution.width
30910
+ } : {}
30911
+ })), {
30912
+ acceptCodecs: accept,
30913
+ allowTranscode,
30914
+ preferredProviders,
30915
+ resolutionPreference,
30916
+ ...preferences?.targetHeight === void 0 ? {} : { targetHeight: preferences.targetHeight }
30917
+ });
30918
+ if (ranked === null) return null;
30919
+ const winner = candidates.find((s) => s.camStreamId === ranked.candidate.camStreamId);
30920
+ if (winner === void 0) return null;
29639
30921
  const reasonParts = [];
29640
30922
  reasonParts.push(`codec=${winner.codec ?? "unknown"}`);
29641
30923
  if (winner.resolution) reasonParts.push(`res=${winner.resolution.width}x${winner.resolution.height}`);
29642
30924
  reasonParts.push(`provider=${providerRank(winner.camStreamId)}`);
30925
+ reasonParts.push(ranked.transcodes ? "transcode" : "passthrough");
30926
+ if (preferences?.targetHeight !== void 0) reasonParts.push(`target=${preferences.targetHeight}`);
29643
30927
  if (requireSibling.length > 0) reasonParts.push(`bypassed=${requireSibling.join("|")}`);
29644
30928
  return {
29645
30929
  camStreamId: winner.camStreamId,
29646
30930
  codec: winner.codec,
29647
30931
  resolution: winner.resolution,
30932
+ transcodes: ranked.transcodes,
29648
30933
  reason: reasonParts.join(" ")
29649
30934
  };
29650
30935
  }
@@ -30116,6 +31401,11 @@ var StreamBrokerAddon = class extends BaseAddon {
30116
31401
  const localNode = this.ctx.kernel.localNodeId;
30117
31402
  const localNodeId = localNode ? localNode.includes("/") ? localNode.split("/")[0] : localNode : void 0;
30118
31403
  this.brokerManager.setApiAccess(this.ctx.api, localNodeId, this.ctx.kernel.hwaccel);
31404
+ const readSignals = async (deviceId) => {
31405
+ return await (await this.ctx.fetchDevice(deviceId)).streamSignals?.listSignals({}) ?? [];
31406
+ };
31407
+ this.brokerManager.setDeviceSignalReader(async (deviceId) => (await readSignals(deviceId)).map((s) => s.code));
31408
+ this.brokerManager.setDeviceSignalDetailReader(readSignals);
30119
31409
  await this.loadFfmpegConfig();
30120
31410
  await this.declareRowCollections();
30121
31411
  await this.purgeFlattenedSettingsKeys();