@camstack/addon-pipeline 1.2.68 → 1.2.70

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 (28) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +32 -30
  4. package/dist/detection-pipeline/index.mjs +32 -30
  5. package/dist/{dist-CwUPxdAB.mjs → dist-BPaa4_z6.mjs} +525 -9
  6. package/dist/{dist-Bv9CqUAF.js → dist-cR-pvD_z.js} +525 -9
  7. package/dist/{event-loop-stall-monitor-D2dL-_bE.mjs → event-loop-stall-monitor-B79REtL2.mjs} +1 -1
  8. package/dist/{event-loop-stall-monitor-Bz3lx_aY.js → event-loop-stall-monitor-STRdnQWm.js} +1 -1
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +8 -5
  12. package/dist/pipeline-runner/index.mjs +8 -5
  13. package/dist/recorder/index.js +5195 -3820
  14. package/dist/recorder/index.mjs +5195 -3820
  15. package/dist/session-decode/decode-worker-child.js +1 -1
  16. package/dist/session-decode/decode-worker-child.mjs +1 -1
  17. package/dist/stream-broker/_stub.js +2 -2
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cl4eU4eN.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B-HGBNad.mjs} +3 -3
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D1CqbPrT.mjs +26 -0
  20. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DU7u9EJt.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-NjvjJWz-.mjs} +1 -1
  21. package/dist/stream-broker/{hostInit-_NJ0GBOO.mjs → hostInit-B12DHOv7.mjs} +3 -3
  22. package/dist/stream-broker/index.js +501 -32
  23. package/dist/stream-broker/index.mjs +501 -32
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/{worker-protocol-D8M43suz.js → worker-protocol-C7V1qIIA.js} +1 -1
  26. package/dist/{worker-protocol-0uQE5Lfd.mjs → worker-protocol-CBO8nwQj.mjs} +1 -1
  27. package/package.json +1 -1
  28. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-ircnzt0s.mjs +0 -26
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-Bv9CqUAF.js");
6
+ const require_dist = require("../dist-cR-pvD_z.js");
7
7
  const require_remote_restream = require("../remote-restream-BYbAsgUf.js");
8
8
  let node_crypto = require("node:crypto");
9
9
  node_crypto = require_chunk.__toESM(node_crypto, 1);
@@ -1015,6 +1015,111 @@ function buildDerivedTranscodeArgs(profile, loopbackUrl, decodeHwAccel = null) {
1015
1015
  decodeHwAccel
1016
1016
  }));
1017
1017
  }
1018
+ /**
1019
+ * Ceiling for a configured pre-roll.
1020
+ *
1021
+ * Three costs grow with this number and none of them is the operator's to
1022
+ * discover: retained RTP bytes on the ingest node, the media backlog a decoder
1023
+ * must drain before it reaches the live edge, and the span a detection burst is
1024
+ * allowed to carry past the D57 bound. 6 s covers a 4-5 s IDR interval plus a
1025
+ * full GOP of head-room and matches the decoder-seed ring's own retention, so
1026
+ * both planes can honour the same maximum.
1027
+ */
1028
+ var PRE_ROLL_MAX_MS = 6e3;
1029
+ var NOTHING = {
1030
+ startIndex: -1,
1031
+ ageMs: 0,
1032
+ gopsBack: 0
1033
+ };
1034
+ /** A configured pre-roll in ms, or `0` when the input carries no instruction. */
1035
+ function usablePreRoll(value) {
1036
+ if (value === void 0) return 0;
1037
+ if (!Number.isFinite(value) || value <= 0) return 0;
1038
+ return Math.min(PRE_ROLL_MAX_MS, Math.max(250, value));
1039
+ }
1040
+ /**
1041
+ * Choose the keyframe access unit a pre-roll burst starts at.
1042
+ *
1043
+ * The OLDEST mark that is still inside the window wins; when none is (the
1044
+ * current GOP is itself older than `preRollMs`, i.e. a long-GOP camera with a
1045
+ * short pre-roll) the newest mark is returned, because the alternative to the
1046
+ * current GOP is nothing at all — the blind start Stage 0 exists to prevent.
1047
+ *
1048
+ * The boundary is INCLUSIVE: a keyframe exactly `preRollMs` old is inside.
1049
+ * Everything delivered after a keyframe is younger than it, so starting at a
1050
+ * mark inside the window is what makes "no frame older than `preRollMs` is
1051
+ * delivered" a property of the slice rather than a per-packet filter.
1052
+ *
1053
+ * Scans instead of assuming order: a caller that appends marks and rebases
1054
+ * indices after a trim is one refactor away from handing this an unsorted
1055
+ * array, and the failure would be a silently wider window.
1056
+ */
1057
+ function selectPreRollStart(marks, preRollMs) {
1058
+ if (marks.length === 0) return NOTHING;
1059
+ const window = usablePreRoll(preRollMs);
1060
+ let newest = marks[0];
1061
+ let chosen = null;
1062
+ for (const mark of marks) {
1063
+ if (mark.ageMs < newest.ageMs) newest = mark;
1064
+ if (window > 0 && mark.ageMs <= window && (chosen === null || mark.ageMs > chosen.ageMs)) chosen = mark;
1065
+ }
1066
+ const pick = chosen ?? newest;
1067
+ let gopsBack = 0;
1068
+ for (const mark of marks) if (mark.ageMs < pick.ageMs) gopsBack++;
1069
+ return {
1070
+ startIndex: pick.index,
1071
+ ageMs: pick.ageMs,
1072
+ gopsBack
1073
+ };
1074
+ }
1075
+ /**
1076
+ * Combine the cluster default and the per-camera-stream override into the one
1077
+ * pre-roll a broker applies. Precedence: stream override → cluster default →
1078
+ * OFF. Each layer is validated independently, so a bad override falls back to a
1079
+ * good cluster value rather than to nothing.
1080
+ *
1081
+ * `0` means INHERIT, never "off for this stream". It is what a cleared number
1082
+ * input posts, and the shipped cluster default is 0, so inherit and off
1083
+ * coincide on every unconfigured stream.
1084
+ */
1085
+ function resolvePreRollMs(input) {
1086
+ const override = usablePreRoll(input.overrides?.[input.camStreamId]);
1087
+ if (override > 0) return override;
1088
+ if (input.terminalRelay === true) return 0;
1089
+ return usablePreRoll(input.clusterMs);
1090
+ }
1091
+ /**
1092
+ * The packet ceiling for a burst carrying `preRollMs` of pre-roll.
1093
+ *
1094
+ * `PRIME_MAX_PACKETS` is a defensive backstop against RTP timestamps that
1095
+ * stopped describing the stream — "far more packets than this much media time
1096
+ * can contain" — not a tuning knob. Read as an absolute it would silently kill
1097
+ * pre-roll on exactly the cameras that need it (a 4K main stream at 8 Mbps
1098
+ * fills 2 000 MTU-sized packets in under 3 s), so it is applied as the RATE it
1099
+ * always was: the same packets-per-span, over the span actually requested.
1100
+ * Never shrinks the ceiling.
1101
+ */
1102
+ function preRollPacketCeiling(bound, preRollMs) {
1103
+ const window = usablePreRoll(preRollMs);
1104
+ if (window <= 0 || bound.maxSpanMs <= 0) return bound.maxPackets;
1105
+ const factor = Math.max(1, Math.ceil(window / bound.maxSpanMs));
1106
+ return bound.maxPackets * factor;
1107
+ }
1108
+ /**
1109
+ * The span bound a DETECTION session's burst is held to while pre-roll is on.
1110
+ *
1111
+ * D57's bound is untouched for every other consumer; for this one it becomes
1112
+ * the wider of the two, because withholding a burst for being as wide as the
1113
+ * operator configured would make the knob read as ON and behave as OFF.
1114
+ */
1115
+ function preRollSpanBound(bound, preRollMs) {
1116
+ const window = usablePreRoll(preRollMs);
1117
+ if (window <= 0) return bound;
1118
+ return {
1119
+ maxSpanMs: Math.max(bound.maxSpanMs, window),
1120
+ maxPackets: preRollPacketCeiling(bound, window)
1121
+ };
1122
+ }
1018
1123
  //#endregion
1019
1124
  //#region src/stream-broker/rtsp/ffmpeg-source-reader-args.ts
1020
1125
  /** MJPEG cannot enter the broker's MPEG-TS demuxer directly; normalize it. */
@@ -1645,6 +1750,20 @@ var RtspRestreamer = class {
1645
1750
  */
1646
1751
  primeBoundSupplier = null;
1647
1752
  /**
1753
+ * Supplies the PRE-ROLL window — the current GOP plus up to `preRollMs` of
1754
+ * the history before it, keyframe-aligned (motion-start Stage 2).
1755
+ *
1756
+ * A second supplier and not a widening of {@link rtpPreBufferSupplier},
1757
+ * because the two feed different populations: that one is also the WebRTC
1758
+ * bootstrap and every ordinary RTSP join, and handing them history is the
1759
+ * D57 failure. Only a session that declared the detection intent on its dial
1760
+ * path is served from this one.
1761
+ *
1762
+ * Pulled per join for the same reason as the bound: a broker outlives the
1763
+ * setting.
1764
+ */
1765
+ preRollSupplier = null;
1766
+ /**
1648
1767
  * In-flight paced join replays, keyed by session id. A session with an entry
1649
1768
  * here has been removed from {@link pendingKeyframe} but is NOT yet on the
1650
1769
  * live feed: live packets are appended to its replay tail via `enqueue`, so
@@ -1687,6 +1806,10 @@ var RtspRestreamer = class {
1687
1806
  setPrimeBoundSupplier(supplier) {
1688
1807
  this.primeBoundSupplier = supplier;
1689
1808
  }
1809
+ /** Wire the pre-roll window accessor (see field doc). */
1810
+ setPreRollSupplier(supplier) {
1811
+ this.preRollSupplier = supplier;
1812
+ }
1690
1813
  /** The bound in force right now; {@link DEFAULT_PRIME_BURST_BOUND} unwired. */
1691
1814
  currentPrimeBound() {
1692
1815
  return this.primeBoundSupplier?.() ?? DEFAULT_PRIME_BURST_BOUND;
@@ -1928,16 +2051,37 @@ var RtspRestreamer = class {
1928
2051
  if (this.sessions.get(sessionId)?.isPlaying()) ready.push(sessionId);
1929
2052
  }
1930
2053
  if (ready.length === 0) return;
2054
+ const preRoll = this.preRollSupplier?.();
2055
+ const preRollPackets = fromRing && preRoll && preRoll.preRollMs > 0 && preRoll.packets.length > primePackets.length ? preRoll.packets.map((data) => ({
2056
+ data,
2057
+ marker: (data[1] & 128) !== 0
2058
+ })) : [];
2059
+ const preRollActive = preRollPackets.length > 0;
2060
+ const preRollSpanMs = preRollActive ? (preRollPackets[preRollPackets.length - 1].data.readUInt32BE(4) - preRollPackets[0].data.readUInt32BE(4) | 0) / 90 : 0;
1931
2061
  const bound = this.currentPrimeBound();
1932
2062
  const overSpan = spanMs > bound.maxSpanMs;
1933
2063
  const overPackets = primePackets.length > bound.maxPackets;
2064
+ const preRollBound = preRollActive ? preRollSpanBound(bound, preRoll?.preRollMs ?? 0) : bound;
2065
+ const preRollOverBound = preRollActive ? preRollSpanMs > preRollBound.maxSpanMs || preRollPackets.length > preRollBound.maxPackets : false;
1934
2066
  const served = [];
2067
+ const servedPreRoll = /* @__PURE__ */ new Set();
1935
2068
  let withheld = 0;
1936
2069
  let detectionIntentServed = 0;
1937
2070
  for (const sessionId of ready) {
1938
2071
  const session = this.sessions.get(sessionId);
1939
2072
  if (!session) continue;
1940
2073
  const detectionIntent = session.isDetectionIntent();
2074
+ if (detectionIntent && preRollActive) {
2075
+ if (preRollOverBound) {
2076
+ this.dropsWithheldSpan++;
2077
+ withheld++;
2078
+ continue;
2079
+ }
2080
+ detectionIntentServed++;
2081
+ servedPreRoll.add(sessionId);
2082
+ served.push(sessionId);
2083
+ continue;
2084
+ }
1941
2085
  if (session.isMuted() && !detectionIntent) {
1942
2086
  if (!fromRing || spanMs > LIVE_EDGE_MAX_SPAN_MS) {
1943
2087
  this.dropsWithheldMutedLiveEdge++;
@@ -1966,7 +2110,11 @@ var RtspRestreamer = class {
1966
2110
  boundSpanMs: bound.maxSpanMs,
1967
2111
  boundPackets: bound.maxPackets,
1968
2112
  withheld,
1969
- detectionIntentServed
2113
+ detectionIntentServed,
2114
+ preRollMs: preRoll?.preRollMs ?? 0,
2115
+ preRollSpanMs: preRollActive ? Math.round(preRollSpanMs) : 0,
2116
+ preRollPackets: preRollPackets.length,
2117
+ preRollServed: servedPreRoll.size
1970
2118
  }
1971
2119
  });
1972
2120
  const items = primePackets.map((packet) => ({
@@ -1974,9 +2122,14 @@ var RtspRestreamer = class {
1974
2122
  prime: true,
1975
2123
  keyframeAuStart: false
1976
2124
  }));
2125
+ const preRollItems = servedPreRoll.size > 0 ? preRollPackets.map((packet) => ({
2126
+ packet,
2127
+ prime: true,
2128
+ keyframeAuStart: false
2129
+ })) : [];
1977
2130
  for (const sessionId of served) {
1978
2131
  this.pendingKeyframe.delete(sessionId);
1979
- this.startPrimeReplay(sessionId, items);
2132
+ this.startPrimeReplay(sessionId, servedPreRoll.has(sessionId) ? preRollItems : items);
1980
2133
  }
1981
2134
  }
1982
2135
  /**
@@ -10468,11 +10621,14 @@ var StreamBroker = class StreamBroker {
10468
10621
  * WebRTC viewer — that closed the broker, tore the lib's session
10469
10622
  * down, and on reopen the dedicated session attached cleanly.
10470
10623
  *
10471
- * This watchdog automates that: it arms when the rfc4571 reader's
10472
- * `onVideoTrack` fires (TCP up, SDP parsed) and cancels at the first
10624
+ * This watchdog automates that: it arms when either reader's
10625
+ * `onVideoTrack` fires (TCP up, SDP parsed) the callback factory is
10626
+ * shared, so native RTSP arms it too — and cancels at the first
10473
10627
  * `onVideoRtp` callback. If it elapses without a single video RTP
10474
- * packet, it destroys the reader, asks the publisher for a fresh
10475
- * source, and re-dials exactly what the manual restart did.
10628
+ * packet (measured PER DIAL `videoRtpSeen` is reset on every dial),
10629
+ * it destroys the reader and re-dials; for an `rfc4571` loopback source
10630
+ * it also asks the publisher for a fresh source first. Exactly what the
10631
+ * manual restart did.
10476
10632
  */
10477
10633
  firstVideoWatchdog;
10478
10634
  /**
@@ -10487,23 +10643,27 @@ var StreamBroker = class StreamBroker {
10487
10643
  static FIRST_VIDEO_TIMEOUT_MS = 8e3;
10488
10644
  /**
10489
10645
  * Rolling source-RTP activity watchdog. Distinct from the first-video
10490
- * watchdog: this one ARMS on the first RTP packet (video or audio) and
10491
- * RESETS on every subsequent packet. If no packet arrives within
10646
+ * watchdog: this one ARMS at DIAL time (so a connection that opens the
10647
+ * loopback TCP but never delivers media is still detected) and RESETS on
10648
+ * every RTP packet thereafter. If no packet arrives within
10492
10649
  * `RTP_ACTIVITY_TIMEOUT_MS`, the reader is torn down and reconnected.
10493
10650
  *
10494
- * Wi-Fi-attached IP cameras silently stall the RTSP/TCP source under
10495
- * packet loss: ICE/DTLS stay healthy, `rtpPacketsSent` keeps advancing
10496
- * (replaying the GOP cache), but the source-side audio/video time
10497
- * progresses only in tiny bursts. The user sees a frozen viewport with
10498
- * no recovery because the existing code path never noticed the stall.
10651
+ * Two failure modes, one timer:
10652
+ * - a rebooted-Reolink rfc4571 source that opens the socket but never
10653
+ * starts its dedicated session (arm-at-dial catches this in ~8s); and
10654
+ * - a Wi-Fi-attached IP camera that silently stalls mid-stream under
10655
+ * packet loss ICE/DTLS stay healthy and `rtpPacketsSent` keeps
10656
+ * advancing (replaying the GOP cache), but source-side time progresses
10657
+ * only in tiny bursts, so the viewer freezes with no recovery.
10499
10658
  *
10500
- * The watchdog resets on every RTP packet and kills+restarts the reader
10501
- * when no data arrives within `RTP_ACTIVITY_TIMEOUT_MS` (~10s).
10659
+ * The watchdog kills+restarts the reader when no data arrives within
10660
+ * `RTP_ACTIVITY_TIMEOUT_MS` (8s same as `FIRST_VIDEO_TIMEOUT_MS`).
10502
10661
  *
10503
10662
  * Reused across the native RTSP and RFC 4571 readers — both flow through
10504
10663
  * `buildRtpStreamCallbacks` and call `kickRtpActivityWatchdog()` per
10505
- * packet. push-rtp / push (provider-driven) and RTMP sources sit out
10506
- * because they have no native reader to tear down.
10664
+ * packet, and both arm it at dial. push-rtp arms it on its first pushed
10665
+ * packet; push / RTMP (provider-driven) sources sit out because they have
10666
+ * no native reader to tear down.
10507
10667
  */
10508
10668
  rtpActivityWatchdog;
10509
10669
  /** Number of times the activity watchdog fired in this broker's lifetime
@@ -10633,6 +10793,28 @@ var StreamBroker = class StreamBroker {
10633
10793
  rtpRingHasKeyframe = false;
10634
10794
  rtpRingBytes = 0;
10635
10795
  /**
10796
+ * Index + 90 kHz timestamp of the first packet of every keyframe AU still in
10797
+ * {@link rtpRing}, oldest first.
10798
+ *
10799
+ * Exists because pre-roll needs to start a burst at an EARLIER keyframe and
10800
+ * nothing in an RTP header says "keyframe" — keyframe-ness is known only at
10801
+ * capture time, from the depacketizer flags. Without these marks the only
10802
+ * reachable start is "the whole ring", which is a mid-GOP cut the moment the
10803
+ * ring holds more than one GOP: undecodable, and exactly what D57 refuses.
10804
+ *
10805
+ * Indices are rebased on every head trim, so the last entry is always the
10806
+ * newest keyframe AU — the current GOP's start, which is what
10807
+ * {@link getRtpPreBuffer} keeps handing WebRTC and the plain RTSP join.
10808
+ */
10809
+ rtpRingKeyframeMarks = [];
10810
+ /**
10811
+ * Media time (ms) of history a DETECTION decode dial may be handed before the
10812
+ * live edge — motion-start Stage 2. `0` (default) = off, and every path here
10813
+ * then behaves exactly as it did before pre-roll existed. Resolved per stream
10814
+ * by `resolvePreRollMs` and pushed by the manager.
10815
+ */
10816
+ preRollMs = 0;
10817
+ /**
10636
10818
  * Effective join-burst bound for this stream's RTSP restream — the cluster
10637
10819
  * default, or this camera stream's override. Applies to the RESTREAM
10638
10820
  * consumers only; the WebRTC bootstrap reads the ring directly and is
@@ -10645,6 +10827,20 @@ var StreamBroker = class StreamBroker {
10645
10827
  * long-GOP high-bitrate stream) drops the bootstrap rather than balloon
10646
10828
  * memory — late joiners fall back to waiting for the next keyframe. */
10647
10829
  static RTP_RING_MAX_BYTES = 24 * 1024 * 1024;
10830
+ /**
10831
+ * Byte budget for the EXTRA history pre-roll retains, over and above the
10832
+ * current GOP.
10833
+ *
10834
+ * Its own budget rather than a share of {@link RTP_RING_MAX_BYTES} because
10835
+ * the two failures are different: reaching the ring cap calls
10836
+ * `resetRtpPreBuffer`, which drops the join bootstrap of EVERY consumer —
10837
+ * WebRTC viewers included — for as long as the condition holds. A pre-roll
10838
+ * that pushed a 4K stream into that state would be a knob for one camera's
10839
+ * detection quietly degrading everybody else's picture. Over this budget the
10840
+ * OLDEST retained GOP is dropped whole (never a tail cut), so the window
10841
+ * stays decodable and simply carries less history than asked for.
10842
+ */
10843
+ static PRE_ROLL_MAX_BYTES = 8 * 1024 * 1024;
10648
10844
  /** Stream stats tracking */
10649
10845
  totalBytes = 0;
10650
10846
  bytesInWindow = 0;
@@ -10684,6 +10880,10 @@ var StreamBroker = class StreamBroker {
10684
10880
  if (logger) this.rtspRestreamer.setLogger(logger.child("rtsp"));
10685
10881
  this.rtspRestreamer.setRtpPreBufferSupplier(() => this.getRtpPreBuffer());
10686
10882
  this.rtspRestreamer.setPrimeBoundSupplier(() => this.joinBurstBound);
10883
+ this.rtspRestreamer.setPreRollSupplier(() => ({
10884
+ packets: this.getRtpPreRollBuffer(),
10885
+ preRollMs: this.preRollMs
10886
+ }));
10687
10887
  this.preBuffer = new EncodedRingBuffer(StreamBroker.DEFAULT_PRE_BUFFER_SEC);
10688
10888
  this.decoderSeedBuffer = new EncodedRingBuffer(StreamBroker.DECODER_SEED_BUFFER_SEC);
10689
10889
  this.audioChunkPlane = new AudioChunkPlane(logger?.child("audio-chunk-plane"));
@@ -10892,6 +11092,7 @@ var StreamBroker = class StreamBroker {
10892
11092
  this._status = "connecting";
10893
11093
  if (this.dialCount > 0) this.notifySourceRestart();
10894
11094
  this.dialCount += 1;
11095
+ this.videoRtpSeen = 0;
10895
11096
  if (source.ffmpegParser) {
10896
11097
  this._status = "connecting";
10897
11098
  this.startFfmpegSourceReader(source);
@@ -11587,11 +11788,16 @@ var StreamBroker = class StreamBroker {
11587
11788
  this.rtpRing.push(rtpData);
11588
11789
  this.rtpRingBytes += rtpData.length;
11589
11790
  if (this._lastNalParamSet || this._lastNalKeyframe) {
11590
- if (this.rtpRingCurAuStart > 0) {
11591
- const dropped = this.rtpRing.splice(0, this.rtpRingCurAuStart);
11592
- for (const b of dropped) this.rtpRingBytes -= b.length;
11593
- this.rtpRingCurAuStart = 0;
11791
+ const auStart = this.rtpRingCurAuStart;
11792
+ const lastMark = this.rtpRingKeyframeMarks[this.rtpRingKeyframeMarks.length - 1];
11793
+ if (!lastMark || lastMark.index !== auStart) {
11794
+ const auPacket = this.rtpRing[auStart];
11795
+ if (auPacket && auPacket.length >= 8) this.rtpRingKeyframeMarks.push({
11796
+ index: auStart,
11797
+ ts: auPacket.readUInt32BE(4)
11798
+ });
11594
11799
  }
11800
+ this.trimRtpRingToPreRollWindow();
11595
11801
  this.rtpRingHasKeyframe = true;
11596
11802
  }
11597
11803
  this.rtpRingPrevMarker = marker;
@@ -11679,6 +11885,95 @@ var StreamBroker = class StreamBroker {
11679
11885
  this.rtpRingCurAuStart = 0;
11680
11886
  this.rtpRingPrevMarker = true;
11681
11887
  this.rtpRingHasKeyframe = false;
11888
+ this.rtpRingKeyframeMarks = [];
11889
+ }
11890
+ /**
11891
+ * Configure how much history a DETECTION dial may be handed (Stage 2).
11892
+ *
11893
+ * Also widens the decoder-seed ring so the PUSH (Annex-B) plane can honour
11894
+ * the same number: that ring is what `getSeedPackets` replays into a
11895
+ * freshly-armed decoder, and a 6 s retention cannot serve a 6 s pre-roll plus
11896
+ * the GOP that carries it.
11897
+ */
11898
+ setPreRollMs(ms) {
11899
+ const next = Number.isFinite(ms) && ms > 0 ? ms : 0;
11900
+ if (next === this.preRollMs) return;
11901
+ this.preRollMs = next;
11902
+ const seedSec = Math.max(StreamBroker.DECODER_SEED_BUFFER_SEC, Math.ceil(next / 1e3) + 2);
11903
+ this.decoderSeedBuffer.setDuration(seedSec);
11904
+ }
11905
+ /** The pre-roll in force for this stream (ms). `0` = off. */
11906
+ getPreRollMs() {
11907
+ return this.preRollMs;
11908
+ }
11909
+ /**
11910
+ * The seed replayed into a freshly-armed PUSH-mode decoder session.
11911
+ *
11912
+ * The current GOP (`getPackets()`) while pre-roll is off — today's behaviour —
11913
+ * and the same keyframe-aligned pre-roll window otherwise, cut from the seed
11914
+ * ring's whole retention rather than from its last keyframe. This is the
11915
+ * push/Annex-B counterpart of {@link getRtpPreRollBuffer}: RTP sources decode
11916
+ * in PULL mode off the restream and take their pre-roll as the join burst,
11917
+ * push sources are fed packet by packet and take it here. One selector serves
11918
+ * both, so a fix to the boundary cannot land on only one plane.
11919
+ */
11920
+ getDecoderSeedPackets() {
11921
+ if (this.preRollMs <= 0) return this.decoderSeedBuffer.getPackets();
11922
+ const all = this.decoderSeedBuffer.getAllPackets();
11923
+ if (all.length === 0) return [];
11924
+ const newestPts = all[all.length - 1].pts;
11925
+ const marks = [];
11926
+ for (let i = 0; i < all.length; i++) {
11927
+ const packet = all[i];
11928
+ if (packet.keyframe && packet.type === "video") marks.push({
11929
+ index: i,
11930
+ ageMs: newestPts - packet.pts
11931
+ });
11932
+ }
11933
+ const selection = selectPreRollStart(marks, this.preRollMs);
11934
+ return selection.startIndex < 0 ? [] : all.slice(selection.startIndex);
11935
+ }
11936
+ /** Retained keyframe AUs as ages in ms of media time behind `newestTs`. */
11937
+ rtpPreRollMarks(newestTs) {
11938
+ return this.rtpRingKeyframeMarks.map((mark) => ({
11939
+ index: mark.index,
11940
+ ageMs: (newestTs - mark.ts | 0) / 90
11941
+ }));
11942
+ }
11943
+ /**
11944
+ * Trim the RTP ring's head to the oldest keyframe AU still inside the
11945
+ * pre-roll window, then to the pre-roll byte budget.
11946
+ *
11947
+ * With `preRollMs === 0` the selection is the newest keyframe AU, so this is
11948
+ * the pre-existing "trim to the current GOP" behaviour with no change in
11949
+ * retained bytes. Whole GOPs only, from the head: a tail cut no longer starts
11950
+ * at a keyframe (D57).
11951
+ */
11952
+ trimRtpRingToPreRollWindow() {
11953
+ if (this.rtpRingKeyframeMarks.length === 0 || this.rtpRing.length === 0) return;
11954
+ const newestTs = this.rtpRing[this.rtpRing.length - 1].readUInt32BE(4);
11955
+ let cut = selectPreRollStart(this.rtpPreRollMarks(newestTs), this.preRollMs).startIndex;
11956
+ if (cut < 0) return;
11957
+ if (this.preRollMs > 0) {
11958
+ const historyEnd = this.rtpRingKeyframeMarks[this.rtpRingKeyframeMarks.length - 1].index;
11959
+ let retained = 0;
11960
+ for (let i = cut; i < historyEnd; i++) retained += this.rtpRing[i].length;
11961
+ let markIdx = this.rtpRingKeyframeMarks.findIndex((m) => m.index === cut);
11962
+ while (retained > StreamBroker.PRE_ROLL_MAX_BYTES && markIdx >= 0 && markIdx < this.rtpRingKeyframeMarks.length - 1) {
11963
+ const nextCut = this.rtpRingKeyframeMarks[markIdx + 1].index;
11964
+ for (let i = cut; i < nextCut; i++) retained -= this.rtpRing[i].length;
11965
+ cut = nextCut;
11966
+ markIdx++;
11967
+ }
11968
+ }
11969
+ if (cut <= 0) return;
11970
+ const dropped = this.rtpRing.splice(0, cut);
11971
+ for (const b of dropped) this.rtpRingBytes -= b.length;
11972
+ this.rtpRingKeyframeMarks = this.rtpRingKeyframeMarks.filter((mark) => mark.index >= cut).map((mark) => ({
11973
+ index: mark.index - cut,
11974
+ ts: mark.ts
11975
+ }));
11976
+ this.rtpRingCurAuStart = Math.max(0, this.rtpRingCurAuStart - cut);
11682
11977
  }
11683
11978
  /**
11684
11979
  * Snapshot of the source-RTP pre-buffer (the current GOP from its
@@ -11688,7 +11983,25 @@ var StreamBroker = class StreamBroker {
11688
11983
  * falls back to waiting for the camera's next keyframe.
11689
11984
  */
11690
11985
  getRtpPreBuffer() {
11691
- return this.rtpRingHasKeyframe ? this.rtpRing.slice() : [];
11986
+ if (!this.rtpRingHasKeyframe) return [];
11987
+ const newest = this.rtpRingKeyframeMarks[this.rtpRingKeyframeMarks.length - 1];
11988
+ return newest ? this.rtpRing.slice(newest.index) : this.rtpRing.slice();
11989
+ }
11990
+ /**
11991
+ * Snapshot for a DETECTION decode dial: the current GOP plus up to
11992
+ * {@link preRollMs} of the history before it, starting at a keyframe AU.
11993
+ *
11994
+ * Identical to {@link getRtpPreBuffer} while pre-roll is off, and it degrades
11995
+ * to it whenever the ring holds no older keyframe — a source that was idle
11996
+ * when motion fired retained nothing, and the honest outcome there is the
11997
+ * Stage 0 burst rather than a pretend one.
11998
+ */
11999
+ getRtpPreRollBuffer() {
12000
+ if (!this.rtpRingHasKeyframe) return [];
12001
+ if (this.preRollMs <= 0 || this.rtpRing.length === 0) return this.getRtpPreBuffer();
12002
+ const newestTs = this.rtpRing[this.rtpRing.length - 1].readUInt32BE(4);
12003
+ const selection = selectPreRollStart(this.rtpPreRollMarks(newestTs), this.preRollMs);
12004
+ return selection.startIndex < 0 ? [] : this.rtpRing.slice(selection.startIndex);
11692
12005
  }
11693
12006
  getSourceType() {
11694
12007
  return this.source?.type ?? null;
@@ -11787,6 +12100,19 @@ var StreamBroker = class StreamBroker {
11787
12100
  };
11788
12101
  }
11789
12102
  /**
12103
+ * Test seam exposing the first-video watchdog state and the per-dial video
12104
+ * RTP counter. Production code MUST NOT read this — it exists so unit tests
12105
+ * can assert the counter resets on every dial (regression: a broker-lifetime
12106
+ * counter left the first-video watchdog dead on every re-dial) and that the
12107
+ * watchdog stands down once video flows.
12108
+ */
12109
+ getFirstVideoWatchdogStatus() {
12110
+ return {
12111
+ armed: this.firstVideoWatchdog !== void 0,
12112
+ videoRtpSeen: this.videoRtpSeen
12113
+ };
12114
+ }
12115
+ /**
11790
12116
  * Open a decoded audio-chunk subscription — the poll-based, tRPC-reachable
11791
12117
  * replacement for the live-object `onDecodedAudioChunk` callback. The
11792
12118
  * broker registers a per-subscription FIFO queue and returns a
@@ -11840,7 +12166,7 @@ var StreamBroker = class StreamBroker {
11840
12166
  logger: this.logger?.child("frame-handle-plane"),
11841
12167
  resolveStreamInfo: () => this.resolveFrameHandleStreamInfo(),
11842
12168
  localNodeId: this.localNodeId,
11843
- getSeedPackets: () => this.decoderSeedBuffer.getPackets(),
12169
+ getSeedPackets: () => this.getDecoderSeedPackets(),
11844
12170
  isDebugEnabled: () => this.streamingDebug
11845
12171
  });
11846
12172
  return this.frameHandlePlane;
@@ -12219,7 +12545,7 @@ var StreamBroker = class StreamBroker {
12219
12545
  * runs `destroyNativeClient`; the RFC 4571 reader closes the TCP
12220
12546
  * socket and clears its handle).
12221
12547
  */
12222
- buildRtpStreamCallbacks(label, onTerminate) {
12548
+ buildRtpStreamCallbacks(label, onTerminate, isStale) {
12223
12549
  return {
12224
12550
  onVideoTrack: (track, sdpText) => {
12225
12551
  this.detectedCodec = track.codec;
@@ -12392,7 +12718,18 @@ var StreamBroker = class StreamBroker {
12392
12718
  this.scheduleReconnect();
12393
12719
  },
12394
12720
  onTeardown: () => {
12721
+ if (this.manualStop || this.stopping) return;
12722
+ if (isStale?.()) return;
12395
12723
  onTerminate();
12724
+ this._status = "error";
12725
+ this.logger?.warn(`${label}: source closed — treating as failure edge`, {
12726
+ tags: { deviceId: this.numericDeviceId },
12727
+ meta: { ...this.sourceMetaForLog() }
12728
+ });
12729
+ if (this.source?.type === "rfc4571") {
12730
+ this.notifySourceRefresh();
12731
+ this.scheduleReconnect();
12732
+ }
12396
12733
  }
12397
12734
  };
12398
12735
  }
@@ -12401,7 +12738,7 @@ var StreamBroker = class StreamBroker {
12401
12738
  const client = new NativeRtspClient({
12402
12739
  url: source.url,
12403
12740
  logger: this.logger?.child("rtsp-native")
12404
- }, this.buildRtpStreamCallbacks("native RTSP", () => this.destroyNativeClient()));
12741
+ }, this.buildRtpStreamCallbacks("native RTSP", () => this.destroyNativeClient(), () => this.nativeClient !== client));
12405
12742
  this.nativeClient = client;
12406
12743
  client.connect().catch((err) => {
12407
12744
  if (this.manualStop || this._suspended) return;
@@ -12413,6 +12750,7 @@ var StreamBroker = class StreamBroker {
12413
12750
  this._status = "error";
12414
12751
  this.scheduleReconnect();
12415
12752
  });
12753
+ this.kickRtpActivityWatchdog();
12416
12754
  }
12417
12755
  /**
12418
12756
  * Handle a complete NAL from the RTP depacketizer.
@@ -12563,15 +12901,29 @@ var StreamBroker = class StreamBroker {
12563
12901
  this.firstVideoWatchdog = setTimeout(() => {
12564
12902
  this.firstVideoWatchdog = void 0;
12565
12903
  if (this.manualStop || this.stopping) return;
12566
- if (this.videoRtpSeen > 0) return;
12567
- this.logger?.warn("first-video watchdog: no video RTP after rfc4571 connect forcing reconnect", { meta: { timeoutMs: StreamBroker.FIRST_VIDEO_TIMEOUT_MS } });
12568
- this.videoRtpSeen = 0;
12904
+ if (this.videoRtpSeen > 0) {
12905
+ this.logger?.info("first-video watchdog: video flowingstanding down", {
12906
+ tags: { deviceId: this.numericDeviceId },
12907
+ meta: {
12908
+ videoRtpSeen: this.videoRtpSeen,
12909
+ ...this.sourceMetaForLog()
12910
+ }
12911
+ });
12912
+ return;
12913
+ }
12914
+ this.logger?.warn("first-video watchdog: no video RTP after source connect — forcing reconnect", {
12915
+ tags: { deviceId: this.numericDeviceId },
12916
+ meta: {
12917
+ timeoutMs: StreamBroker.FIRST_VIDEO_TIMEOUT_MS,
12918
+ ...this.sourceMetaForLog()
12919
+ }
12920
+ });
12569
12921
  this.destroyRfc4571Reader();
12570
12922
  this._status = "error";
12571
12923
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
12572
12924
  this.nextDialAllowedAt = 0;
12573
12925
  this.lastRefreshRequestAt = 0;
12574
- this.notifySourceRefresh();
12926
+ if (this.source?.type === "rfc4571") this.notifySourceRefresh();
12575
12927
  this.scheduleReconnect();
12576
12928
  }, StreamBroker.FIRST_VIDEO_TIMEOUT_MS);
12577
12929
  }
@@ -12796,7 +13148,7 @@ var StreamBroker = class StreamBroker {
12796
13148
  url: source.url,
12797
13149
  sdp,
12798
13150
  ...this.logger ? { logger: this.logger.child("rfc4571") } : {}
12799
- }, this.buildRtpStreamCallbacks("rfc4571", () => this.destroyRfc4571Reader()));
13151
+ }, this.buildRtpStreamCallbacks("rfc4571", () => this.destroyRfc4571Reader(), () => this.rfc4571Reader !== reader));
12800
13152
  this.rfc4571Reader = reader;
12801
13153
  reader.connect().catch((err) => {
12802
13154
  if (this.manualStop || this._suspended) return;
@@ -12809,6 +13161,7 @@ var StreamBroker = class StreamBroker {
12809
13161
  this.notifySourceRefresh();
12810
13162
  this.scheduleReconnect();
12811
13163
  });
13164
+ this.kickRtpActivityWatchdog();
12812
13165
  }
12813
13166
  destroyRfc4571Reader() {
12814
13167
  if (this.rfc4571Reader) {
@@ -13484,6 +13837,7 @@ var SourceParserArgsEntrySchema = require_dist.object({
13484
13837
  var DeviceOverrideSchema = require_dist.object({
13485
13838
  preBuffer: require_dist.record(require_dist.string(), StreamPreBufferSchema).optional(),
13486
13839
  joinBurstMaxSpanMs: require_dist.record(require_dist.string(), require_dist.number()).optional(),
13840
+ preRollMs: require_dist.record(require_dist.string(), require_dist.number()).optional(),
13487
13841
  streamingDebug: require_dist.boolean().optional(),
13488
13842
  audioMuted: require_dist.boolean().optional(),
13489
13843
  webrtcDebug: require_dist.boolean().optional(),
@@ -13545,6 +13899,18 @@ function resolveBrokerTier(brokerId, localLookup, remoteBrokerTiers) {
13545
13899
  * Watchdog parameters for the stream-health emitter. Brokers that have
13546
13900
  * received zero video packets for STREAM_STALE_TIMEOUT_MS are reported
13547
13901
  * as `stream.offline`; the next packet flips them back to `stream.online`.
13902
+ *
13903
+ * THE TWO MINUTES ARE AN OPERATOR CONTRACT, not a tuning knob (2026-08-12).
13904
+ * A `stale-timeout` is the ONLY thing downstream is allowed to call an outage
13905
+ * (Notification Center: `notification-center/event-intake.ts`,
13906
+ * `NC_STREAM_OFFLINE_REASON`), and the operator's rule for it is "a camera
13907
+ * reboot must not get through" — a stream that comes back inside this window
13908
+ * produces no notification at all, because no `stream.offline` is emitted in
13909
+ * the first place. Shortening this window shortens that promise, and nothing
13910
+ * downstream re-checks it: this constant IS the two minutes.
13911
+ *
13912
+ * Nothing emits `stream.offline` faster. The rfc4571 clean-FIN failure edge
13913
+ * (`onTeardown`) sets `_status='error'` and reconnects; it does not emit.
13548
13914
  */
13549
13915
  var STREAM_STALE_TIMEOUT_MS = 12e4;
13550
13916
  var STREAM_HEALTH_POLL_MS = 15e3;
@@ -13655,6 +14021,15 @@ var StreamBrokerManager = class StreamBrokerManager {
13655
14021
  * regardless of prior state.
13656
14022
  */
13657
14023
  streamHealthByBroker = /* @__PURE__ */ new Map();
14024
+ /**
14025
+ * Why each broker last went UNhealthy. Read when it comes back so the
14026
+ * recovery names the same kind of transition its outage did: a broker that
14027
+ * went quiet because the last consumer left comes back `resumed`, not
14028
+ * `recovered`. Without this, an intentional suspend/resume cycle emitted the
14029
+ * same pair a two-minute outage does, and every consumer downstream had to
14030
+ * guess which one it was looking at.
14031
+ */
14032
+ streamOfflineReasonByBroker = /* @__PURE__ */ new Map();
13658
14033
  streamHealthTimer;
13659
14034
  /**
13660
14035
  * Routes a frame-handle `subscriptionId` (Phase 5 / D9) back to its owning
@@ -13747,6 +14122,13 @@ var StreamBrokerManager = class StreamBrokerManager {
13747
14122
  * happened to own its ingest (D52/D56).
13748
14123
  */
13749
14124
  defaultJoinBurstMaxSpanMs;
14125
+ /**
14126
+ * Cluster-wide default pre-roll (ms) — motion-start Stage 2. Cluster-scoped
14127
+ * for the same reason as the join-burst bound (a movable ingest role must not
14128
+ * change a camera's behaviour), and 0 by default: pre-roll spends retained
14129
+ * bytes on the ingest node, so it is turned on per camera that needs it.
14130
+ */
14131
+ defaultPreRollMs = 0;
13750
14132
  webrtcServer = null;
13751
14133
  /**
13752
14134
  * brokerId → the remote-encoded adapter backing a non-owned camera's WebRTC
@@ -14361,6 +14743,32 @@ var StreamBrokerManager = class StreamBrokerManager {
14361
14743
  overrides: this.deviceOverrides.get(deviceId)?.joinBurstMaxSpanMs
14362
14744
  }));
14363
14745
  }
14746
+ /**
14747
+ * Set the cluster-wide default pre-roll and push the newly effective value
14748
+ * into every live broker — same reasoning as the join-burst bound: a broker
14749
+ * outlives the setting that configured it.
14750
+ */
14751
+ setDefaultPreRollMs(ms) {
14752
+ this.defaultPreRollMs = ms;
14753
+ for (const [brokerId, broker] of this.brokers) {
14754
+ const parsed = parseBrokerId(brokerId);
14755
+ if (!parsed) continue;
14756
+ this.applyPreRoll(broker, parsed.deviceId, parsed.camStreamId);
14757
+ }
14758
+ }
14759
+ /**
14760
+ * Resolve and install this stream's pre-roll (Stage 2). A `pull-http` relay
14761
+ * — the terminal cameras — takes the cluster default as 0 (operator
14762
+ * decision); an explicit per-stream override still reaches it.
14763
+ */
14764
+ applyPreRoll(broker, deviceId, camStreamId) {
14765
+ broker.setPreRollMs(resolvePreRollMs({
14766
+ camStreamId,
14767
+ clusterMs: this.defaultPreRollMs,
14768
+ overrides: this.deviceOverrides.get(deviceId)?.preRollMs,
14769
+ terminalRelay: this.cameraStreams.get(deviceId)?.get(camStreamId)?.kind === "pull-http"
14770
+ }));
14771
+ }
14364
14772
  setEventBus(bus) {
14365
14773
  this.eventBus = bus;
14366
14774
  this.startStreamHealthWatchdog();
@@ -15922,6 +16330,7 @@ var StreamBrokerManager = class StreamBrokerManager {
15922
16330
  await Promise.all(stopPromises);
15923
16331
  this.brokers.clear();
15924
16332
  this.streamHealthByBroker.clear();
16333
+ this.streamOfflineReasonByBroker.clear();
15925
16334
  this.frameSubscriptionBroker.clear();
15926
16335
  this.audioSubscriptionBroker.clear();
15927
16336
  await this.rtspServer.stop();
@@ -15930,6 +16339,18 @@ var StreamBrokerManager = class StreamBrokerManager {
15930
16339
  if (this.streamHealthTimer) return;
15931
16340
  this.streamHealthTimer = setInterval(() => this.evaluateStreamHealth(), STREAM_HEALTH_POLL_MS);
15932
16341
  }
16342
+ /**
16343
+ * @internal Test seam. Registers a pre-built broker under `brokerId` and runs
16344
+ * ONE stream-health evaluation pass synchronously. Production registers
16345
+ * brokers through the publish/assignment path and runs the sweep on the
16346
+ * {@link STREAM_HEALTH_POLL_MS} interval; this exists only so the
16347
+ * level-triggered phantom-streaming redial (the self-healing backstop) can be
16348
+ * asserted in isolation without standing up the full assignment pipeline.
16349
+ */
16350
+ runStreamHealthSweepForTest(brokerId, broker) {
16351
+ this.brokers.set(brokerId, broker);
16352
+ this.evaluateStreamHealth();
16353
+ }
15933
16354
  evaluateStreamHealth() {
15934
16355
  if (!this.eventBus) return;
15935
16356
  const now = Date.now();
@@ -15940,8 +16361,12 @@ var StreamBrokerManager = class StreamBrokerManager {
15940
16361
  const lastPacketAt = broker.getLastPacketAt();
15941
16362
  const wasHealthy = this.streamHealthByBroker.get(brokerId);
15942
16363
  const isHealthy = lastPacketAt > 0 && now - lastPacketAt <= STREAM_STALE_TIMEOUT_MS;
16364
+ if (!isHealthy && broker.getStats().status === "streaming") broker.redial("stream-health sweep: phantom streaming, no packets within stale window");
15943
16365
  if (wasHealthy === isHealthy) continue;
15944
16366
  this.streamHealthByBroker.set(brokerId, isHealthy);
16367
+ const reason = isHealthy ? wasHealthy === void 0 ? "first-packet" : this.streamOfflineReasonByBroker.get(brokerId) === "suspended" ? "resumed" : "recovered" : broker.suspended ? "suspended" : "stale-timeout";
16368
+ if (isHealthy) this.streamOfflineReasonByBroker.delete(brokerId);
16369
+ else this.streamOfflineReasonByBroker.set(brokerId, reason);
15945
16370
  this.emitStreamHealth(isHealthy, {
15946
16371
  deviceId,
15947
16372
  camStreamId,
@@ -15949,7 +16374,7 @@ var StreamBrokerManager = class StreamBrokerManager {
15949
16374
  brokerId,
15950
16375
  sourceType: broker.getActiveSourceType(),
15951
16376
  lastPacketAt,
15952
- reason: isHealthy ? wasHealthy === void 0 ? "first-packet" : "recovered" : "stale-timeout"
16377
+ reason
15953
16378
  });
15954
16379
  }
15955
16380
  }
@@ -16444,6 +16869,19 @@ var StreamBrokerManager = class StreamBrokerManager {
16444
16869
  default: 0,
16445
16870
  value: override?.joinBurstMaxSpanMs?.[camStreamId] ?? 0
16446
16871
  },
16872
+ {
16873
+ type: "number",
16874
+ key: `preRollMs:${camStreamId}`,
16875
+ label: "Detection pre-roll",
16876
+ description: "How much video from BEFORE the motion edge a detection decode is handed when it attaches, so the first analysed frames predate the trigger. Costs retained memory on the ingest node while this stream is dialed, and only reaches back as far as the stream has been warm — it never dials a camera. Served to the detection decode alone: viewers, recordings and HomeKit legs are unaffected. 0 = inherit the cluster default (off).",
16877
+ min: 0,
16878
+ max: PRE_ROLL_MAX_MS,
16879
+ step: 250,
16880
+ unit: "ms",
16881
+ span: 2,
16882
+ default: 0,
16883
+ value: override?.preRollMs?.[camStreamId] ?? 0
16884
+ },
16447
16885
  {
16448
16886
  type: "select",
16449
16887
  key: `hwaccel:${camStreamId}`,
@@ -16699,6 +17137,22 @@ var StreamBrokerManager = class StreamBrokerManager {
16699
17137
  }));
16700
17138
  continue;
16701
17139
  }
17140
+ if (fieldKey.startsWith("preRollMs:")) {
17141
+ const camStreamId = fieldKey.slice(10);
17142
+ overrideDirty = true;
17143
+ const pr = { ...nextOverride.preRollMs };
17144
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) pr[camStreamId] = Math.min(PRE_ROLL_MAX_MS, Math.max(250, value));
17145
+ else delete pr[camStreamId];
17146
+ nextOverride.preRollMs = pr;
17147
+ const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
17148
+ if (broker) broker.setPreRollMs(resolvePreRollMs({
17149
+ camStreamId,
17150
+ clusterMs: this.defaultPreRollMs,
17151
+ overrides: pr,
17152
+ terminalRelay: this.cameraStreams.get(deviceId)?.get(camStreamId)?.kind === "pull-http"
17153
+ }));
17154
+ continue;
17155
+ }
16702
17156
  if (fieldKey.startsWith("hwaccel:")) {
16703
17157
  const camStreamId = fieldKey.slice(8);
16704
17158
  overrideDirty = true;
@@ -17386,6 +17840,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17386
17840
  this.applyPreBufferConfig(broker, deviceId, camStreamId);
17387
17841
  this.applyAudioMuteConfig(broker, deviceId);
17388
17842
  this.applyJoinBurstBound(broker, deviceId, camStreamId);
17843
+ this.applyPreRoll(broker, deviceId, camStreamId);
17389
17844
  this.applyClipRetention(deviceId);
17390
17845
  if (this.deviceOverrides.get(deviceId)?.streamingDebug) broker.setStreamingDebug(true);
17391
17846
  broker.setSourceProvider(() => {
@@ -17435,6 +17890,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17435
17890
  reason: "broker-destroyed"
17436
17891
  });
17437
17892
  this.streamHealthByBroker.delete(brokerId);
17893
+ this.streamOfflineReasonByBroker.delete(brokerId);
17438
17894
  await broker.stop();
17439
17895
  this.brokers.delete(brokerId);
17440
17896
  this.sweepFrameSubscriptions(brokerId);
@@ -33607,6 +34063,7 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
33607
34063
  maxReconnectDelayMs: 3e4,
33608
34064
  catalogReconcileIntervalSec: 30,
33609
34065
  joinBurstMaxSpanMs: DEFAULT_PRIME_BURST_BOUND.maxSpanMs,
34066
+ preRollMs: 0,
33610
34067
  binaryPath: "",
33611
34068
  hwaccel: "copy"
33612
34069
  });
@@ -33665,6 +34122,7 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
33665
34122
  this.brokerManager = new StreamBrokerManager(void 0, this.ctx.logger);
33666
34123
  this.brokerManager.setDefaultPreBufferSec(this.config.defaultPreBufferSec);
33667
34124
  this.brokerManager.setDefaultJoinBurstMaxSpanMs(this.config.joinBurstMaxSpanMs);
34125
+ this.brokerManager.setDefaultPreRollMs(this.config.preRollMs);
33668
34126
  this.brokerManager.setEventBus(this.ctx.eventBus);
33669
34127
  const localNode = this.ctx.kernel.localNodeId;
33670
34128
  const localNodeId = localNode ? localNode.includes("/") ? localNode.split("/")[0] : localNode : void 0;
@@ -34200,6 +34658,17 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
34200
34658
  default: DEFAULT_PRIME_BURST_BOUND.maxSpanMs,
34201
34659
  unit: "ms"
34202
34660
  },
34661
+ {
34662
+ type: "number",
34663
+ key: "preRollMs",
34664
+ label: "Detection pre-roll",
34665
+ description: "Cluster-wide default for how much video from BEFORE the motion edge a detection decode is handed when it attaches, so the first analysed frames predate the trigger. Off (0) by default: it retains extra video in memory on whichever node ingests the camera, and it can only reach as far back as that stream has been warm — it never dials a camera on its own. Only the detection decode receives it; viewers, recordings and HomeKit legs keep the ordinary join burst. Terminal (MJPEG relay) cameras ignore this default. Override per camera stream.",
34666
+ min: 0,
34667
+ max: PRE_ROLL_MAX_MS,
34668
+ step: 250,
34669
+ default: 0,
34670
+ unit: "ms"
34671
+ },
34203
34672
  {
34204
34673
  type: "number",
34205
34674
  key: "catalogReconcileIntervalSec",