@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
@@ -1,5 +1,5 @@
1
1
  import { r as __toESM, t as __commonJSMin } from "../chunk-DnnnRqeS.mjs";
2
- import { At as string, Ct as boolean, D as cameraStreamsCapability, Dt as number, Et as literal, F as egressTranscodeSharingKey, I as egressTransportFromRequest, Mt as EventCategory, O as createHwAccelCache, Ot as object, Q as streamBrokerCapability, S as RingBuffer, St as array, V as maskUrlCredentials, Y as resolveEgressDecodeHwAccel, at as invocationFromEncodeProfile, ct as CAM_PROFILE_ORDER, dt as createEvent, et as webrtcSessionCapability, f as EncodeProfileSchema, gt as nodePin, ht as makeSourceBrokerId, it as buildFfmpegArgs$1, jt as union, kt as record, lt as DeviceFeature, mt as makeProfileBrokerId, nt as AUDIO_PRESETS, ot as isSoftwareDecode, pt as isEvent, rt as Fmp4BoxSplitter, st as BaseAddon, tt as errMsg, ut as DeviceType, v as RATE_CONTROL_RELAXED, vt as parseProfileBrokerId, w as addonWidgetsSourceCapability, wt as discriminatedUnion, xt as _enum, y as RATE_CONTROL_TIGHT } from "../dist-CwUPxdAB.mjs";
2
+ import { At as string, Ct as boolean, D as cameraStreamsCapability, Dt as number, Et as literal, F as egressTranscodeSharingKey, I as egressTransportFromRequest, Mt as EventCategory, O as createHwAccelCache, Ot as object, Q as streamBrokerCapability, S as RingBuffer, St as array, V as maskUrlCredentials, Y as resolveEgressDecodeHwAccel, at as invocationFromEncodeProfile, ct as CAM_PROFILE_ORDER, dt as createEvent, et as webrtcSessionCapability, f as EncodeProfileSchema, gt as nodePin, ht as makeSourceBrokerId, it as buildFfmpegArgs$1, jt as union, kt as record, lt as DeviceFeature, mt as makeProfileBrokerId, nt as AUDIO_PRESETS, ot as isSoftwareDecode, pt as isEvent, rt as Fmp4BoxSplitter, st as BaseAddon, tt as errMsg, ut as DeviceType, v as RATE_CONTROL_RELAXED, vt as parseProfileBrokerId, w as addonWidgetsSourceCapability, wt as discriminatedUnion, xt as _enum, y as RATE_CONTROL_TIGHT } from "../dist-BPaa4_z6.mjs";
3
3
  import { n as profileForStreamId, r as parseRestreamPath } from "../remote-restream-Ci7RXNGb.mjs";
4
4
  import { createRequire } from "node:module";
5
5
  import * as crypto$1 from "node:crypto";
@@ -1009,6 +1009,111 @@ function buildDerivedTranscodeArgs(profile, loopbackUrl, decodeHwAccel = null) {
1009
1009
  decodeHwAccel
1010
1010
  }));
1011
1011
  }
1012
+ /**
1013
+ * Ceiling for a configured pre-roll.
1014
+ *
1015
+ * Three costs grow with this number and none of them is the operator's to
1016
+ * discover: retained RTP bytes on the ingest node, the media backlog a decoder
1017
+ * must drain before it reaches the live edge, and the span a detection burst is
1018
+ * allowed to carry past the D57 bound. 6 s covers a 4-5 s IDR interval plus a
1019
+ * full GOP of head-room and matches the decoder-seed ring's own retention, so
1020
+ * both planes can honour the same maximum.
1021
+ */
1022
+ var PRE_ROLL_MAX_MS = 6e3;
1023
+ var NOTHING = {
1024
+ startIndex: -1,
1025
+ ageMs: 0,
1026
+ gopsBack: 0
1027
+ };
1028
+ /** A configured pre-roll in ms, or `0` when the input carries no instruction. */
1029
+ function usablePreRoll(value) {
1030
+ if (value === void 0) return 0;
1031
+ if (!Number.isFinite(value) || value <= 0) return 0;
1032
+ return Math.min(PRE_ROLL_MAX_MS, Math.max(250, value));
1033
+ }
1034
+ /**
1035
+ * Choose the keyframe access unit a pre-roll burst starts at.
1036
+ *
1037
+ * The OLDEST mark that is still inside the window wins; when none is (the
1038
+ * current GOP is itself older than `preRollMs`, i.e. a long-GOP camera with a
1039
+ * short pre-roll) the newest mark is returned, because the alternative to the
1040
+ * current GOP is nothing at all — the blind start Stage 0 exists to prevent.
1041
+ *
1042
+ * The boundary is INCLUSIVE: a keyframe exactly `preRollMs` old is inside.
1043
+ * Everything delivered after a keyframe is younger than it, so starting at a
1044
+ * mark inside the window is what makes "no frame older than `preRollMs` is
1045
+ * delivered" a property of the slice rather than a per-packet filter.
1046
+ *
1047
+ * Scans instead of assuming order: a caller that appends marks and rebases
1048
+ * indices after a trim is one refactor away from handing this an unsorted
1049
+ * array, and the failure would be a silently wider window.
1050
+ */
1051
+ function selectPreRollStart(marks, preRollMs) {
1052
+ if (marks.length === 0) return NOTHING;
1053
+ const window = usablePreRoll(preRollMs);
1054
+ let newest = marks[0];
1055
+ let chosen = null;
1056
+ for (const mark of marks) {
1057
+ if (mark.ageMs < newest.ageMs) newest = mark;
1058
+ if (window > 0 && mark.ageMs <= window && (chosen === null || mark.ageMs > chosen.ageMs)) chosen = mark;
1059
+ }
1060
+ const pick = chosen ?? newest;
1061
+ let gopsBack = 0;
1062
+ for (const mark of marks) if (mark.ageMs < pick.ageMs) gopsBack++;
1063
+ return {
1064
+ startIndex: pick.index,
1065
+ ageMs: pick.ageMs,
1066
+ gopsBack
1067
+ };
1068
+ }
1069
+ /**
1070
+ * Combine the cluster default and the per-camera-stream override into the one
1071
+ * pre-roll a broker applies. Precedence: stream override → cluster default →
1072
+ * OFF. Each layer is validated independently, so a bad override falls back to a
1073
+ * good cluster value rather than to nothing.
1074
+ *
1075
+ * `0` means INHERIT, never "off for this stream". It is what a cleared number
1076
+ * input posts, and the shipped cluster default is 0, so inherit and off
1077
+ * coincide on every unconfigured stream.
1078
+ */
1079
+ function resolvePreRollMs(input) {
1080
+ const override = usablePreRoll(input.overrides?.[input.camStreamId]);
1081
+ if (override > 0) return override;
1082
+ if (input.terminalRelay === true) return 0;
1083
+ return usablePreRoll(input.clusterMs);
1084
+ }
1085
+ /**
1086
+ * The packet ceiling for a burst carrying `preRollMs` of pre-roll.
1087
+ *
1088
+ * `PRIME_MAX_PACKETS` is a defensive backstop against RTP timestamps that
1089
+ * stopped describing the stream — "far more packets than this much media time
1090
+ * can contain" — not a tuning knob. Read as an absolute it would silently kill
1091
+ * pre-roll on exactly the cameras that need it (a 4K main stream at 8 Mbps
1092
+ * fills 2 000 MTU-sized packets in under 3 s), so it is applied as the RATE it
1093
+ * always was: the same packets-per-span, over the span actually requested.
1094
+ * Never shrinks the ceiling.
1095
+ */
1096
+ function preRollPacketCeiling(bound, preRollMs) {
1097
+ const window = usablePreRoll(preRollMs);
1098
+ if (window <= 0 || bound.maxSpanMs <= 0) return bound.maxPackets;
1099
+ const factor = Math.max(1, Math.ceil(window / bound.maxSpanMs));
1100
+ return bound.maxPackets * factor;
1101
+ }
1102
+ /**
1103
+ * The span bound a DETECTION session's burst is held to while pre-roll is on.
1104
+ *
1105
+ * D57's bound is untouched for every other consumer; for this one it becomes
1106
+ * the wider of the two, because withholding a burst for being as wide as the
1107
+ * operator configured would make the knob read as ON and behave as OFF.
1108
+ */
1109
+ function preRollSpanBound(bound, preRollMs) {
1110
+ const window = usablePreRoll(preRollMs);
1111
+ if (window <= 0) return bound;
1112
+ return {
1113
+ maxSpanMs: Math.max(bound.maxSpanMs, window),
1114
+ maxPackets: preRollPacketCeiling(bound, window)
1115
+ };
1116
+ }
1012
1117
  //#endregion
1013
1118
  //#region src/stream-broker/rtsp/ffmpeg-source-reader-args.ts
1014
1119
  /** MJPEG cannot enter the broker's MPEG-TS demuxer directly; normalize it. */
@@ -1639,6 +1744,20 @@ var RtspRestreamer = class {
1639
1744
  */
1640
1745
  primeBoundSupplier = null;
1641
1746
  /**
1747
+ * Supplies the PRE-ROLL window — the current GOP plus up to `preRollMs` of
1748
+ * the history before it, keyframe-aligned (motion-start Stage 2).
1749
+ *
1750
+ * A second supplier and not a widening of {@link rtpPreBufferSupplier},
1751
+ * because the two feed different populations: that one is also the WebRTC
1752
+ * bootstrap and every ordinary RTSP join, and handing them history is the
1753
+ * D57 failure. Only a session that declared the detection intent on its dial
1754
+ * path is served from this one.
1755
+ *
1756
+ * Pulled per join for the same reason as the bound: a broker outlives the
1757
+ * setting.
1758
+ */
1759
+ preRollSupplier = null;
1760
+ /**
1642
1761
  * In-flight paced join replays, keyed by session id. A session with an entry
1643
1762
  * here has been removed from {@link pendingKeyframe} but is NOT yet on the
1644
1763
  * live feed: live packets are appended to its replay tail via `enqueue`, so
@@ -1681,6 +1800,10 @@ var RtspRestreamer = class {
1681
1800
  setPrimeBoundSupplier(supplier) {
1682
1801
  this.primeBoundSupplier = supplier;
1683
1802
  }
1803
+ /** Wire the pre-roll window accessor (see field doc). */
1804
+ setPreRollSupplier(supplier) {
1805
+ this.preRollSupplier = supplier;
1806
+ }
1684
1807
  /** The bound in force right now; {@link DEFAULT_PRIME_BURST_BOUND} unwired. */
1685
1808
  currentPrimeBound() {
1686
1809
  return this.primeBoundSupplier?.() ?? DEFAULT_PRIME_BURST_BOUND;
@@ -1922,16 +2045,37 @@ var RtspRestreamer = class {
1922
2045
  if (this.sessions.get(sessionId)?.isPlaying()) ready.push(sessionId);
1923
2046
  }
1924
2047
  if (ready.length === 0) return;
2048
+ const preRoll = this.preRollSupplier?.();
2049
+ const preRollPackets = fromRing && preRoll && preRoll.preRollMs > 0 && preRoll.packets.length > primePackets.length ? preRoll.packets.map((data) => ({
2050
+ data,
2051
+ marker: (data[1] & 128) !== 0
2052
+ })) : [];
2053
+ const preRollActive = preRollPackets.length > 0;
2054
+ const preRollSpanMs = preRollActive ? (preRollPackets[preRollPackets.length - 1].data.readUInt32BE(4) - preRollPackets[0].data.readUInt32BE(4) | 0) / 90 : 0;
1925
2055
  const bound = this.currentPrimeBound();
1926
2056
  const overSpan = spanMs > bound.maxSpanMs;
1927
2057
  const overPackets = primePackets.length > bound.maxPackets;
2058
+ const preRollBound = preRollActive ? preRollSpanBound(bound, preRoll?.preRollMs ?? 0) : bound;
2059
+ const preRollOverBound = preRollActive ? preRollSpanMs > preRollBound.maxSpanMs || preRollPackets.length > preRollBound.maxPackets : false;
1928
2060
  const served = [];
2061
+ const servedPreRoll = /* @__PURE__ */ new Set();
1929
2062
  let withheld = 0;
1930
2063
  let detectionIntentServed = 0;
1931
2064
  for (const sessionId of ready) {
1932
2065
  const session = this.sessions.get(sessionId);
1933
2066
  if (!session) continue;
1934
2067
  const detectionIntent = session.isDetectionIntent();
2068
+ if (detectionIntent && preRollActive) {
2069
+ if (preRollOverBound) {
2070
+ this.dropsWithheldSpan++;
2071
+ withheld++;
2072
+ continue;
2073
+ }
2074
+ detectionIntentServed++;
2075
+ servedPreRoll.add(sessionId);
2076
+ served.push(sessionId);
2077
+ continue;
2078
+ }
1935
2079
  if (session.isMuted() && !detectionIntent) {
1936
2080
  if (!fromRing || spanMs > LIVE_EDGE_MAX_SPAN_MS) {
1937
2081
  this.dropsWithheldMutedLiveEdge++;
@@ -1960,7 +2104,11 @@ var RtspRestreamer = class {
1960
2104
  boundSpanMs: bound.maxSpanMs,
1961
2105
  boundPackets: bound.maxPackets,
1962
2106
  withheld,
1963
- detectionIntentServed
2107
+ detectionIntentServed,
2108
+ preRollMs: preRoll?.preRollMs ?? 0,
2109
+ preRollSpanMs: preRollActive ? Math.round(preRollSpanMs) : 0,
2110
+ preRollPackets: preRollPackets.length,
2111
+ preRollServed: servedPreRoll.size
1964
2112
  }
1965
2113
  });
1966
2114
  const items = primePackets.map((packet) => ({
@@ -1968,9 +2116,14 @@ var RtspRestreamer = class {
1968
2116
  prime: true,
1969
2117
  keyframeAuStart: false
1970
2118
  }));
2119
+ const preRollItems = servedPreRoll.size > 0 ? preRollPackets.map((packet) => ({
2120
+ packet,
2121
+ prime: true,
2122
+ keyframeAuStart: false
2123
+ })) : [];
1971
2124
  for (const sessionId of served) {
1972
2125
  this.pendingKeyframe.delete(sessionId);
1973
- this.startPrimeReplay(sessionId, items);
2126
+ this.startPrimeReplay(sessionId, servedPreRoll.has(sessionId) ? preRollItems : items);
1974
2127
  }
1975
2128
  }
1976
2129
  /**
@@ -10462,11 +10615,14 @@ var StreamBroker = class StreamBroker {
10462
10615
  * WebRTC viewer — that closed the broker, tore the lib's session
10463
10616
  * down, and on reopen the dedicated session attached cleanly.
10464
10617
  *
10465
- * This watchdog automates that: it arms when the rfc4571 reader's
10466
- * `onVideoTrack` fires (TCP up, SDP parsed) and cancels at the first
10618
+ * This watchdog automates that: it arms when either reader's
10619
+ * `onVideoTrack` fires (TCP up, SDP parsed) the callback factory is
10620
+ * shared, so native RTSP arms it too — and cancels at the first
10467
10621
  * `onVideoRtp` callback. If it elapses without a single video RTP
10468
- * packet, it destroys the reader, asks the publisher for a fresh
10469
- * source, and re-dials exactly what the manual restart did.
10622
+ * packet (measured PER DIAL `videoRtpSeen` is reset on every dial),
10623
+ * it destroys the reader and re-dials; for an `rfc4571` loopback source
10624
+ * it also asks the publisher for a fresh source first. Exactly what the
10625
+ * manual restart did.
10470
10626
  */
10471
10627
  firstVideoWatchdog;
10472
10628
  /**
@@ -10481,23 +10637,27 @@ var StreamBroker = class StreamBroker {
10481
10637
  static FIRST_VIDEO_TIMEOUT_MS = 8e3;
10482
10638
  /**
10483
10639
  * Rolling source-RTP activity watchdog. Distinct from the first-video
10484
- * watchdog: this one ARMS on the first RTP packet (video or audio) and
10485
- * RESETS on every subsequent packet. If no packet arrives within
10640
+ * watchdog: this one ARMS at DIAL time (so a connection that opens the
10641
+ * loopback TCP but never delivers media is still detected) and RESETS on
10642
+ * every RTP packet thereafter. If no packet arrives within
10486
10643
  * `RTP_ACTIVITY_TIMEOUT_MS`, the reader is torn down and reconnected.
10487
10644
  *
10488
- * Wi-Fi-attached IP cameras silently stall the RTSP/TCP source under
10489
- * packet loss: ICE/DTLS stay healthy, `rtpPacketsSent` keeps advancing
10490
- * (replaying the GOP cache), but the source-side audio/video time
10491
- * progresses only in tiny bursts. The user sees a frozen viewport with
10492
- * no recovery because the existing code path never noticed the stall.
10645
+ * Two failure modes, one timer:
10646
+ * - a rebooted-Reolink rfc4571 source that opens the socket but never
10647
+ * starts its dedicated session (arm-at-dial catches this in ~8s); and
10648
+ * - a Wi-Fi-attached IP camera that silently stalls mid-stream under
10649
+ * packet loss ICE/DTLS stay healthy and `rtpPacketsSent` keeps
10650
+ * advancing (replaying the GOP cache), but source-side time progresses
10651
+ * only in tiny bursts, so the viewer freezes with no recovery.
10493
10652
  *
10494
- * The watchdog resets on every RTP packet and kills+restarts the reader
10495
- * when no data arrives within `RTP_ACTIVITY_TIMEOUT_MS` (~10s).
10653
+ * The watchdog kills+restarts the reader when no data arrives within
10654
+ * `RTP_ACTIVITY_TIMEOUT_MS` (8s same as `FIRST_VIDEO_TIMEOUT_MS`).
10496
10655
  *
10497
10656
  * Reused across the native RTSP and RFC 4571 readers — both flow through
10498
10657
  * `buildRtpStreamCallbacks` and call `kickRtpActivityWatchdog()` per
10499
- * packet. push-rtp / push (provider-driven) and RTMP sources sit out
10500
- * because they have no native reader to tear down.
10658
+ * packet, and both arm it at dial. push-rtp arms it on its first pushed
10659
+ * packet; push / RTMP (provider-driven) sources sit out because they have
10660
+ * no native reader to tear down.
10501
10661
  */
10502
10662
  rtpActivityWatchdog;
10503
10663
  /** Number of times the activity watchdog fired in this broker's lifetime
@@ -10627,6 +10787,28 @@ var StreamBroker = class StreamBroker {
10627
10787
  rtpRingHasKeyframe = false;
10628
10788
  rtpRingBytes = 0;
10629
10789
  /**
10790
+ * Index + 90 kHz timestamp of the first packet of every keyframe AU still in
10791
+ * {@link rtpRing}, oldest first.
10792
+ *
10793
+ * Exists because pre-roll needs to start a burst at an EARLIER keyframe and
10794
+ * nothing in an RTP header says "keyframe" — keyframe-ness is known only at
10795
+ * capture time, from the depacketizer flags. Without these marks the only
10796
+ * reachable start is "the whole ring", which is a mid-GOP cut the moment the
10797
+ * ring holds more than one GOP: undecodable, and exactly what D57 refuses.
10798
+ *
10799
+ * Indices are rebased on every head trim, so the last entry is always the
10800
+ * newest keyframe AU — the current GOP's start, which is what
10801
+ * {@link getRtpPreBuffer} keeps handing WebRTC and the plain RTSP join.
10802
+ */
10803
+ rtpRingKeyframeMarks = [];
10804
+ /**
10805
+ * Media time (ms) of history a DETECTION decode dial may be handed before the
10806
+ * live edge — motion-start Stage 2. `0` (default) = off, and every path here
10807
+ * then behaves exactly as it did before pre-roll existed. Resolved per stream
10808
+ * by `resolvePreRollMs` and pushed by the manager.
10809
+ */
10810
+ preRollMs = 0;
10811
+ /**
10630
10812
  * Effective join-burst bound for this stream's RTSP restream — the cluster
10631
10813
  * default, or this camera stream's override. Applies to the RESTREAM
10632
10814
  * consumers only; the WebRTC bootstrap reads the ring directly and is
@@ -10639,6 +10821,20 @@ var StreamBroker = class StreamBroker {
10639
10821
  * long-GOP high-bitrate stream) drops the bootstrap rather than balloon
10640
10822
  * memory — late joiners fall back to waiting for the next keyframe. */
10641
10823
  static RTP_RING_MAX_BYTES = 24 * 1024 * 1024;
10824
+ /**
10825
+ * Byte budget for the EXTRA history pre-roll retains, over and above the
10826
+ * current GOP.
10827
+ *
10828
+ * Its own budget rather than a share of {@link RTP_RING_MAX_BYTES} because
10829
+ * the two failures are different: reaching the ring cap calls
10830
+ * `resetRtpPreBuffer`, which drops the join bootstrap of EVERY consumer —
10831
+ * WebRTC viewers included — for as long as the condition holds. A pre-roll
10832
+ * that pushed a 4K stream into that state would be a knob for one camera's
10833
+ * detection quietly degrading everybody else's picture. Over this budget the
10834
+ * OLDEST retained GOP is dropped whole (never a tail cut), so the window
10835
+ * stays decodable and simply carries less history than asked for.
10836
+ */
10837
+ static PRE_ROLL_MAX_BYTES = 8 * 1024 * 1024;
10642
10838
  /** Stream stats tracking */
10643
10839
  totalBytes = 0;
10644
10840
  bytesInWindow = 0;
@@ -10678,6 +10874,10 @@ var StreamBroker = class StreamBroker {
10678
10874
  if (logger) this.rtspRestreamer.setLogger(logger.child("rtsp"));
10679
10875
  this.rtspRestreamer.setRtpPreBufferSupplier(() => this.getRtpPreBuffer());
10680
10876
  this.rtspRestreamer.setPrimeBoundSupplier(() => this.joinBurstBound);
10877
+ this.rtspRestreamer.setPreRollSupplier(() => ({
10878
+ packets: this.getRtpPreRollBuffer(),
10879
+ preRollMs: this.preRollMs
10880
+ }));
10681
10881
  this.preBuffer = new EncodedRingBuffer(StreamBroker.DEFAULT_PRE_BUFFER_SEC);
10682
10882
  this.decoderSeedBuffer = new EncodedRingBuffer(StreamBroker.DECODER_SEED_BUFFER_SEC);
10683
10883
  this.audioChunkPlane = new AudioChunkPlane(logger?.child("audio-chunk-plane"));
@@ -10886,6 +11086,7 @@ var StreamBroker = class StreamBroker {
10886
11086
  this._status = "connecting";
10887
11087
  if (this.dialCount > 0) this.notifySourceRestart();
10888
11088
  this.dialCount += 1;
11089
+ this.videoRtpSeen = 0;
10889
11090
  if (source.ffmpegParser) {
10890
11091
  this._status = "connecting";
10891
11092
  this.startFfmpegSourceReader(source);
@@ -11581,11 +11782,16 @@ var StreamBroker = class StreamBroker {
11581
11782
  this.rtpRing.push(rtpData);
11582
11783
  this.rtpRingBytes += rtpData.length;
11583
11784
  if (this._lastNalParamSet || this._lastNalKeyframe) {
11584
- if (this.rtpRingCurAuStart > 0) {
11585
- const dropped = this.rtpRing.splice(0, this.rtpRingCurAuStart);
11586
- for (const b of dropped) this.rtpRingBytes -= b.length;
11587
- this.rtpRingCurAuStart = 0;
11785
+ const auStart = this.rtpRingCurAuStart;
11786
+ const lastMark = this.rtpRingKeyframeMarks[this.rtpRingKeyframeMarks.length - 1];
11787
+ if (!lastMark || lastMark.index !== auStart) {
11788
+ const auPacket = this.rtpRing[auStart];
11789
+ if (auPacket && auPacket.length >= 8) this.rtpRingKeyframeMarks.push({
11790
+ index: auStart,
11791
+ ts: auPacket.readUInt32BE(4)
11792
+ });
11588
11793
  }
11794
+ this.trimRtpRingToPreRollWindow();
11589
11795
  this.rtpRingHasKeyframe = true;
11590
11796
  }
11591
11797
  this.rtpRingPrevMarker = marker;
@@ -11673,6 +11879,95 @@ var StreamBroker = class StreamBroker {
11673
11879
  this.rtpRingCurAuStart = 0;
11674
11880
  this.rtpRingPrevMarker = true;
11675
11881
  this.rtpRingHasKeyframe = false;
11882
+ this.rtpRingKeyframeMarks = [];
11883
+ }
11884
+ /**
11885
+ * Configure how much history a DETECTION dial may be handed (Stage 2).
11886
+ *
11887
+ * Also widens the decoder-seed ring so the PUSH (Annex-B) plane can honour
11888
+ * the same number: that ring is what `getSeedPackets` replays into a
11889
+ * freshly-armed decoder, and a 6 s retention cannot serve a 6 s pre-roll plus
11890
+ * the GOP that carries it.
11891
+ */
11892
+ setPreRollMs(ms) {
11893
+ const next = Number.isFinite(ms) && ms > 0 ? ms : 0;
11894
+ if (next === this.preRollMs) return;
11895
+ this.preRollMs = next;
11896
+ const seedSec = Math.max(StreamBroker.DECODER_SEED_BUFFER_SEC, Math.ceil(next / 1e3) + 2);
11897
+ this.decoderSeedBuffer.setDuration(seedSec);
11898
+ }
11899
+ /** The pre-roll in force for this stream (ms). `0` = off. */
11900
+ getPreRollMs() {
11901
+ return this.preRollMs;
11902
+ }
11903
+ /**
11904
+ * The seed replayed into a freshly-armed PUSH-mode decoder session.
11905
+ *
11906
+ * The current GOP (`getPackets()`) while pre-roll is off — today's behaviour —
11907
+ * and the same keyframe-aligned pre-roll window otherwise, cut from the seed
11908
+ * ring's whole retention rather than from its last keyframe. This is the
11909
+ * push/Annex-B counterpart of {@link getRtpPreRollBuffer}: RTP sources decode
11910
+ * in PULL mode off the restream and take their pre-roll as the join burst,
11911
+ * push sources are fed packet by packet and take it here. One selector serves
11912
+ * both, so a fix to the boundary cannot land on only one plane.
11913
+ */
11914
+ getDecoderSeedPackets() {
11915
+ if (this.preRollMs <= 0) return this.decoderSeedBuffer.getPackets();
11916
+ const all = this.decoderSeedBuffer.getAllPackets();
11917
+ if (all.length === 0) return [];
11918
+ const newestPts = all[all.length - 1].pts;
11919
+ const marks = [];
11920
+ for (let i = 0; i < all.length; i++) {
11921
+ const packet = all[i];
11922
+ if (packet.keyframe && packet.type === "video") marks.push({
11923
+ index: i,
11924
+ ageMs: newestPts - packet.pts
11925
+ });
11926
+ }
11927
+ const selection = selectPreRollStart(marks, this.preRollMs);
11928
+ return selection.startIndex < 0 ? [] : all.slice(selection.startIndex);
11929
+ }
11930
+ /** Retained keyframe AUs as ages in ms of media time behind `newestTs`. */
11931
+ rtpPreRollMarks(newestTs) {
11932
+ return this.rtpRingKeyframeMarks.map((mark) => ({
11933
+ index: mark.index,
11934
+ ageMs: (newestTs - mark.ts | 0) / 90
11935
+ }));
11936
+ }
11937
+ /**
11938
+ * Trim the RTP ring's head to the oldest keyframe AU still inside the
11939
+ * pre-roll window, then to the pre-roll byte budget.
11940
+ *
11941
+ * With `preRollMs === 0` the selection is the newest keyframe AU, so this is
11942
+ * the pre-existing "trim to the current GOP" behaviour with no change in
11943
+ * retained bytes. Whole GOPs only, from the head: a tail cut no longer starts
11944
+ * at a keyframe (D57).
11945
+ */
11946
+ trimRtpRingToPreRollWindow() {
11947
+ if (this.rtpRingKeyframeMarks.length === 0 || this.rtpRing.length === 0) return;
11948
+ const newestTs = this.rtpRing[this.rtpRing.length - 1].readUInt32BE(4);
11949
+ let cut = selectPreRollStart(this.rtpPreRollMarks(newestTs), this.preRollMs).startIndex;
11950
+ if (cut < 0) return;
11951
+ if (this.preRollMs > 0) {
11952
+ const historyEnd = this.rtpRingKeyframeMarks[this.rtpRingKeyframeMarks.length - 1].index;
11953
+ let retained = 0;
11954
+ for (let i = cut; i < historyEnd; i++) retained += this.rtpRing[i].length;
11955
+ let markIdx = this.rtpRingKeyframeMarks.findIndex((m) => m.index === cut);
11956
+ while (retained > StreamBroker.PRE_ROLL_MAX_BYTES && markIdx >= 0 && markIdx < this.rtpRingKeyframeMarks.length - 1) {
11957
+ const nextCut = this.rtpRingKeyframeMarks[markIdx + 1].index;
11958
+ for (let i = cut; i < nextCut; i++) retained -= this.rtpRing[i].length;
11959
+ cut = nextCut;
11960
+ markIdx++;
11961
+ }
11962
+ }
11963
+ if (cut <= 0) return;
11964
+ const dropped = this.rtpRing.splice(0, cut);
11965
+ for (const b of dropped) this.rtpRingBytes -= b.length;
11966
+ this.rtpRingKeyframeMarks = this.rtpRingKeyframeMarks.filter((mark) => mark.index >= cut).map((mark) => ({
11967
+ index: mark.index - cut,
11968
+ ts: mark.ts
11969
+ }));
11970
+ this.rtpRingCurAuStart = Math.max(0, this.rtpRingCurAuStart - cut);
11676
11971
  }
11677
11972
  /**
11678
11973
  * Snapshot of the source-RTP pre-buffer (the current GOP from its
@@ -11682,7 +11977,25 @@ var StreamBroker = class StreamBroker {
11682
11977
  * falls back to waiting for the camera's next keyframe.
11683
11978
  */
11684
11979
  getRtpPreBuffer() {
11685
- return this.rtpRingHasKeyframe ? this.rtpRing.slice() : [];
11980
+ if (!this.rtpRingHasKeyframe) return [];
11981
+ const newest = this.rtpRingKeyframeMarks[this.rtpRingKeyframeMarks.length - 1];
11982
+ return newest ? this.rtpRing.slice(newest.index) : this.rtpRing.slice();
11983
+ }
11984
+ /**
11985
+ * Snapshot for a DETECTION decode dial: the current GOP plus up to
11986
+ * {@link preRollMs} of the history before it, starting at a keyframe AU.
11987
+ *
11988
+ * Identical to {@link getRtpPreBuffer} while pre-roll is off, and it degrades
11989
+ * to it whenever the ring holds no older keyframe — a source that was idle
11990
+ * when motion fired retained nothing, and the honest outcome there is the
11991
+ * Stage 0 burst rather than a pretend one.
11992
+ */
11993
+ getRtpPreRollBuffer() {
11994
+ if (!this.rtpRingHasKeyframe) return [];
11995
+ if (this.preRollMs <= 0 || this.rtpRing.length === 0) return this.getRtpPreBuffer();
11996
+ const newestTs = this.rtpRing[this.rtpRing.length - 1].readUInt32BE(4);
11997
+ const selection = selectPreRollStart(this.rtpPreRollMarks(newestTs), this.preRollMs);
11998
+ return selection.startIndex < 0 ? [] : this.rtpRing.slice(selection.startIndex);
11686
11999
  }
11687
12000
  getSourceType() {
11688
12001
  return this.source?.type ?? null;
@@ -11781,6 +12094,19 @@ var StreamBroker = class StreamBroker {
11781
12094
  };
11782
12095
  }
11783
12096
  /**
12097
+ * Test seam exposing the first-video watchdog state and the per-dial video
12098
+ * RTP counter. Production code MUST NOT read this — it exists so unit tests
12099
+ * can assert the counter resets on every dial (regression: a broker-lifetime
12100
+ * counter left the first-video watchdog dead on every re-dial) and that the
12101
+ * watchdog stands down once video flows.
12102
+ */
12103
+ getFirstVideoWatchdogStatus() {
12104
+ return {
12105
+ armed: this.firstVideoWatchdog !== void 0,
12106
+ videoRtpSeen: this.videoRtpSeen
12107
+ };
12108
+ }
12109
+ /**
11784
12110
  * Open a decoded audio-chunk subscription — the poll-based, tRPC-reachable
11785
12111
  * replacement for the live-object `onDecodedAudioChunk` callback. The
11786
12112
  * broker registers a per-subscription FIFO queue and returns a
@@ -11834,7 +12160,7 @@ var StreamBroker = class StreamBroker {
11834
12160
  logger: this.logger?.child("frame-handle-plane"),
11835
12161
  resolveStreamInfo: () => this.resolveFrameHandleStreamInfo(),
11836
12162
  localNodeId: this.localNodeId,
11837
- getSeedPackets: () => this.decoderSeedBuffer.getPackets(),
12163
+ getSeedPackets: () => this.getDecoderSeedPackets(),
11838
12164
  isDebugEnabled: () => this.streamingDebug
11839
12165
  });
11840
12166
  return this.frameHandlePlane;
@@ -12213,7 +12539,7 @@ var StreamBroker = class StreamBroker {
12213
12539
  * runs `destroyNativeClient`; the RFC 4571 reader closes the TCP
12214
12540
  * socket and clears its handle).
12215
12541
  */
12216
- buildRtpStreamCallbacks(label, onTerminate) {
12542
+ buildRtpStreamCallbacks(label, onTerminate, isStale) {
12217
12543
  return {
12218
12544
  onVideoTrack: (track, sdpText) => {
12219
12545
  this.detectedCodec = track.codec;
@@ -12386,7 +12712,18 @@ var StreamBroker = class StreamBroker {
12386
12712
  this.scheduleReconnect();
12387
12713
  },
12388
12714
  onTeardown: () => {
12715
+ if (this.manualStop || this.stopping) return;
12716
+ if (isStale?.()) return;
12389
12717
  onTerminate();
12718
+ this._status = "error";
12719
+ this.logger?.warn(`${label}: source closed — treating as failure edge`, {
12720
+ tags: { deviceId: this.numericDeviceId },
12721
+ meta: { ...this.sourceMetaForLog() }
12722
+ });
12723
+ if (this.source?.type === "rfc4571") {
12724
+ this.notifySourceRefresh();
12725
+ this.scheduleReconnect();
12726
+ }
12390
12727
  }
12391
12728
  };
12392
12729
  }
@@ -12395,7 +12732,7 @@ var StreamBroker = class StreamBroker {
12395
12732
  const client = new NativeRtspClient({
12396
12733
  url: source.url,
12397
12734
  logger: this.logger?.child("rtsp-native")
12398
- }, this.buildRtpStreamCallbacks("native RTSP", () => this.destroyNativeClient()));
12735
+ }, this.buildRtpStreamCallbacks("native RTSP", () => this.destroyNativeClient(), () => this.nativeClient !== client));
12399
12736
  this.nativeClient = client;
12400
12737
  client.connect().catch((err) => {
12401
12738
  if (this.manualStop || this._suspended) return;
@@ -12407,6 +12744,7 @@ var StreamBroker = class StreamBroker {
12407
12744
  this._status = "error";
12408
12745
  this.scheduleReconnect();
12409
12746
  });
12747
+ this.kickRtpActivityWatchdog();
12410
12748
  }
12411
12749
  /**
12412
12750
  * Handle a complete NAL from the RTP depacketizer.
@@ -12557,15 +12895,29 @@ var StreamBroker = class StreamBroker {
12557
12895
  this.firstVideoWatchdog = setTimeout(() => {
12558
12896
  this.firstVideoWatchdog = void 0;
12559
12897
  if (this.manualStop || this.stopping) return;
12560
- if (this.videoRtpSeen > 0) return;
12561
- this.logger?.warn("first-video watchdog: no video RTP after rfc4571 connect forcing reconnect", { meta: { timeoutMs: StreamBroker.FIRST_VIDEO_TIMEOUT_MS } });
12562
- this.videoRtpSeen = 0;
12898
+ if (this.videoRtpSeen > 0) {
12899
+ this.logger?.info("first-video watchdog: video flowingstanding down", {
12900
+ tags: { deviceId: this.numericDeviceId },
12901
+ meta: {
12902
+ videoRtpSeen: this.videoRtpSeen,
12903
+ ...this.sourceMetaForLog()
12904
+ }
12905
+ });
12906
+ return;
12907
+ }
12908
+ this.logger?.warn("first-video watchdog: no video RTP after source connect — forcing reconnect", {
12909
+ tags: { deviceId: this.numericDeviceId },
12910
+ meta: {
12911
+ timeoutMs: StreamBroker.FIRST_VIDEO_TIMEOUT_MS,
12912
+ ...this.sourceMetaForLog()
12913
+ }
12914
+ });
12563
12915
  this.destroyRfc4571Reader();
12564
12916
  this._status = "error";
12565
12917
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
12566
12918
  this.nextDialAllowedAt = 0;
12567
12919
  this.lastRefreshRequestAt = 0;
12568
- this.notifySourceRefresh();
12920
+ if (this.source?.type === "rfc4571") this.notifySourceRefresh();
12569
12921
  this.scheduleReconnect();
12570
12922
  }, StreamBroker.FIRST_VIDEO_TIMEOUT_MS);
12571
12923
  }
@@ -12790,7 +13142,7 @@ var StreamBroker = class StreamBroker {
12790
13142
  url: source.url,
12791
13143
  sdp,
12792
13144
  ...this.logger ? { logger: this.logger.child("rfc4571") } : {}
12793
- }, this.buildRtpStreamCallbacks("rfc4571", () => this.destroyRfc4571Reader()));
13145
+ }, this.buildRtpStreamCallbacks("rfc4571", () => this.destroyRfc4571Reader(), () => this.rfc4571Reader !== reader));
12794
13146
  this.rfc4571Reader = reader;
12795
13147
  reader.connect().catch((err) => {
12796
13148
  if (this.manualStop || this._suspended) return;
@@ -12803,6 +13155,7 @@ var StreamBroker = class StreamBroker {
12803
13155
  this.notifySourceRefresh();
12804
13156
  this.scheduleReconnect();
12805
13157
  });
13158
+ this.kickRtpActivityWatchdog();
12806
13159
  }
12807
13160
  destroyRfc4571Reader() {
12808
13161
  if (this.rfc4571Reader) {
@@ -13478,6 +13831,7 @@ var SourceParserArgsEntrySchema = object({
13478
13831
  var DeviceOverrideSchema = object({
13479
13832
  preBuffer: record(string(), StreamPreBufferSchema).optional(),
13480
13833
  joinBurstMaxSpanMs: record(string(), number()).optional(),
13834
+ preRollMs: record(string(), number()).optional(),
13481
13835
  streamingDebug: boolean().optional(),
13482
13836
  audioMuted: boolean().optional(),
13483
13837
  webrtcDebug: boolean().optional(),
@@ -13539,6 +13893,18 @@ function resolveBrokerTier(brokerId, localLookup, remoteBrokerTiers) {
13539
13893
  * Watchdog parameters for the stream-health emitter. Brokers that have
13540
13894
  * received zero video packets for STREAM_STALE_TIMEOUT_MS are reported
13541
13895
  * as `stream.offline`; the next packet flips them back to `stream.online`.
13896
+ *
13897
+ * THE TWO MINUTES ARE AN OPERATOR CONTRACT, not a tuning knob (2026-08-12).
13898
+ * A `stale-timeout` is the ONLY thing downstream is allowed to call an outage
13899
+ * (Notification Center: `notification-center/event-intake.ts`,
13900
+ * `NC_STREAM_OFFLINE_REASON`), and the operator's rule for it is "a camera
13901
+ * reboot must not get through" — a stream that comes back inside this window
13902
+ * produces no notification at all, because no `stream.offline` is emitted in
13903
+ * the first place. Shortening this window shortens that promise, and nothing
13904
+ * downstream re-checks it: this constant IS the two minutes.
13905
+ *
13906
+ * Nothing emits `stream.offline` faster. The rfc4571 clean-FIN failure edge
13907
+ * (`onTeardown`) sets `_status='error'` and reconnects; it does not emit.
13542
13908
  */
13543
13909
  var STREAM_STALE_TIMEOUT_MS = 12e4;
13544
13910
  var STREAM_HEALTH_POLL_MS = 15e3;
@@ -13649,6 +14015,15 @@ var StreamBrokerManager = class StreamBrokerManager {
13649
14015
  * regardless of prior state.
13650
14016
  */
13651
14017
  streamHealthByBroker = /* @__PURE__ */ new Map();
14018
+ /**
14019
+ * Why each broker last went UNhealthy. Read when it comes back so the
14020
+ * recovery names the same kind of transition its outage did: a broker that
14021
+ * went quiet because the last consumer left comes back `resumed`, not
14022
+ * `recovered`. Without this, an intentional suspend/resume cycle emitted the
14023
+ * same pair a two-minute outage does, and every consumer downstream had to
14024
+ * guess which one it was looking at.
14025
+ */
14026
+ streamOfflineReasonByBroker = /* @__PURE__ */ new Map();
13652
14027
  streamHealthTimer;
13653
14028
  /**
13654
14029
  * Routes a frame-handle `subscriptionId` (Phase 5 / D9) back to its owning
@@ -13741,6 +14116,13 @@ var StreamBrokerManager = class StreamBrokerManager {
13741
14116
  * happened to own its ingest (D52/D56).
13742
14117
  */
13743
14118
  defaultJoinBurstMaxSpanMs;
14119
+ /**
14120
+ * Cluster-wide default pre-roll (ms) — motion-start Stage 2. Cluster-scoped
14121
+ * for the same reason as the join-burst bound (a movable ingest role must not
14122
+ * change a camera's behaviour), and 0 by default: pre-roll spends retained
14123
+ * bytes on the ingest node, so it is turned on per camera that needs it.
14124
+ */
14125
+ defaultPreRollMs = 0;
13744
14126
  webrtcServer = null;
13745
14127
  /**
13746
14128
  * brokerId → the remote-encoded adapter backing a non-owned camera's WebRTC
@@ -14355,6 +14737,32 @@ var StreamBrokerManager = class StreamBrokerManager {
14355
14737
  overrides: this.deviceOverrides.get(deviceId)?.joinBurstMaxSpanMs
14356
14738
  }));
14357
14739
  }
14740
+ /**
14741
+ * Set the cluster-wide default pre-roll and push the newly effective value
14742
+ * into every live broker — same reasoning as the join-burst bound: a broker
14743
+ * outlives the setting that configured it.
14744
+ */
14745
+ setDefaultPreRollMs(ms) {
14746
+ this.defaultPreRollMs = ms;
14747
+ for (const [brokerId, broker] of this.brokers) {
14748
+ const parsed = parseBrokerId(brokerId);
14749
+ if (!parsed) continue;
14750
+ this.applyPreRoll(broker, parsed.deviceId, parsed.camStreamId);
14751
+ }
14752
+ }
14753
+ /**
14754
+ * Resolve and install this stream's pre-roll (Stage 2). A `pull-http` relay
14755
+ * — the terminal cameras — takes the cluster default as 0 (operator
14756
+ * decision); an explicit per-stream override still reaches it.
14757
+ */
14758
+ applyPreRoll(broker, deviceId, camStreamId) {
14759
+ broker.setPreRollMs(resolvePreRollMs({
14760
+ camStreamId,
14761
+ clusterMs: this.defaultPreRollMs,
14762
+ overrides: this.deviceOverrides.get(deviceId)?.preRollMs,
14763
+ terminalRelay: this.cameraStreams.get(deviceId)?.get(camStreamId)?.kind === "pull-http"
14764
+ }));
14765
+ }
14358
14766
  setEventBus(bus) {
14359
14767
  this.eventBus = bus;
14360
14768
  this.startStreamHealthWatchdog();
@@ -15916,6 +16324,7 @@ var StreamBrokerManager = class StreamBrokerManager {
15916
16324
  await Promise.all(stopPromises);
15917
16325
  this.brokers.clear();
15918
16326
  this.streamHealthByBroker.clear();
16327
+ this.streamOfflineReasonByBroker.clear();
15919
16328
  this.frameSubscriptionBroker.clear();
15920
16329
  this.audioSubscriptionBroker.clear();
15921
16330
  await this.rtspServer.stop();
@@ -15924,6 +16333,18 @@ var StreamBrokerManager = class StreamBrokerManager {
15924
16333
  if (this.streamHealthTimer) return;
15925
16334
  this.streamHealthTimer = setInterval(() => this.evaluateStreamHealth(), STREAM_HEALTH_POLL_MS);
15926
16335
  }
16336
+ /**
16337
+ * @internal Test seam. Registers a pre-built broker under `brokerId` and runs
16338
+ * ONE stream-health evaluation pass synchronously. Production registers
16339
+ * brokers through the publish/assignment path and runs the sweep on the
16340
+ * {@link STREAM_HEALTH_POLL_MS} interval; this exists only so the
16341
+ * level-triggered phantom-streaming redial (the self-healing backstop) can be
16342
+ * asserted in isolation without standing up the full assignment pipeline.
16343
+ */
16344
+ runStreamHealthSweepForTest(brokerId, broker) {
16345
+ this.brokers.set(brokerId, broker);
16346
+ this.evaluateStreamHealth();
16347
+ }
15927
16348
  evaluateStreamHealth() {
15928
16349
  if (!this.eventBus) return;
15929
16350
  const now = Date.now();
@@ -15934,8 +16355,12 @@ var StreamBrokerManager = class StreamBrokerManager {
15934
16355
  const lastPacketAt = broker.getLastPacketAt();
15935
16356
  const wasHealthy = this.streamHealthByBroker.get(brokerId);
15936
16357
  const isHealthy = lastPacketAt > 0 && now - lastPacketAt <= STREAM_STALE_TIMEOUT_MS;
16358
+ if (!isHealthy && broker.getStats().status === "streaming") broker.redial("stream-health sweep: phantom streaming, no packets within stale window");
15937
16359
  if (wasHealthy === isHealthy) continue;
15938
16360
  this.streamHealthByBroker.set(brokerId, isHealthy);
16361
+ const reason = isHealthy ? wasHealthy === void 0 ? "first-packet" : this.streamOfflineReasonByBroker.get(brokerId) === "suspended" ? "resumed" : "recovered" : broker.suspended ? "suspended" : "stale-timeout";
16362
+ if (isHealthy) this.streamOfflineReasonByBroker.delete(brokerId);
16363
+ else this.streamOfflineReasonByBroker.set(brokerId, reason);
15939
16364
  this.emitStreamHealth(isHealthy, {
15940
16365
  deviceId,
15941
16366
  camStreamId,
@@ -15943,7 +16368,7 @@ var StreamBrokerManager = class StreamBrokerManager {
15943
16368
  brokerId,
15944
16369
  sourceType: broker.getActiveSourceType(),
15945
16370
  lastPacketAt,
15946
- reason: isHealthy ? wasHealthy === void 0 ? "first-packet" : "recovered" : "stale-timeout"
16371
+ reason
15947
16372
  });
15948
16373
  }
15949
16374
  }
@@ -16438,6 +16863,19 @@ var StreamBrokerManager = class StreamBrokerManager {
16438
16863
  default: 0,
16439
16864
  value: override?.joinBurstMaxSpanMs?.[camStreamId] ?? 0
16440
16865
  },
16866
+ {
16867
+ type: "number",
16868
+ key: `preRollMs:${camStreamId}`,
16869
+ label: "Detection pre-roll",
16870
+ 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).",
16871
+ min: 0,
16872
+ max: PRE_ROLL_MAX_MS,
16873
+ step: 250,
16874
+ unit: "ms",
16875
+ span: 2,
16876
+ default: 0,
16877
+ value: override?.preRollMs?.[camStreamId] ?? 0
16878
+ },
16441
16879
  {
16442
16880
  type: "select",
16443
16881
  key: `hwaccel:${camStreamId}`,
@@ -16693,6 +17131,22 @@ var StreamBrokerManager = class StreamBrokerManager {
16693
17131
  }));
16694
17132
  continue;
16695
17133
  }
17134
+ if (fieldKey.startsWith("preRollMs:")) {
17135
+ const camStreamId = fieldKey.slice(10);
17136
+ overrideDirty = true;
17137
+ const pr = { ...nextOverride.preRollMs };
17138
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) pr[camStreamId] = Math.min(PRE_ROLL_MAX_MS, Math.max(250, value));
17139
+ else delete pr[camStreamId];
17140
+ nextOverride.preRollMs = pr;
17141
+ const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
17142
+ if (broker) broker.setPreRollMs(resolvePreRollMs({
17143
+ camStreamId,
17144
+ clusterMs: this.defaultPreRollMs,
17145
+ overrides: pr,
17146
+ terminalRelay: this.cameraStreams.get(deviceId)?.get(camStreamId)?.kind === "pull-http"
17147
+ }));
17148
+ continue;
17149
+ }
16696
17150
  if (fieldKey.startsWith("hwaccel:")) {
16697
17151
  const camStreamId = fieldKey.slice(8);
16698
17152
  overrideDirty = true;
@@ -17380,6 +17834,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17380
17834
  this.applyPreBufferConfig(broker, deviceId, camStreamId);
17381
17835
  this.applyAudioMuteConfig(broker, deviceId);
17382
17836
  this.applyJoinBurstBound(broker, deviceId, camStreamId);
17837
+ this.applyPreRoll(broker, deviceId, camStreamId);
17383
17838
  this.applyClipRetention(deviceId);
17384
17839
  if (this.deviceOverrides.get(deviceId)?.streamingDebug) broker.setStreamingDebug(true);
17385
17840
  broker.setSourceProvider(() => {
@@ -17429,6 +17884,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17429
17884
  reason: "broker-destroyed"
17430
17885
  });
17431
17886
  this.streamHealthByBroker.delete(brokerId);
17887
+ this.streamOfflineReasonByBroker.delete(brokerId);
17432
17888
  await broker.stop();
17433
17889
  this.brokers.delete(brokerId);
17434
17890
  this.sweepFrameSubscriptions(brokerId);
@@ -33598,6 +34054,7 @@ var StreamBrokerAddon = class extends BaseAddon {
33598
34054
  maxReconnectDelayMs: 3e4,
33599
34055
  catalogReconcileIntervalSec: 30,
33600
34056
  joinBurstMaxSpanMs: DEFAULT_PRIME_BURST_BOUND.maxSpanMs,
34057
+ preRollMs: 0,
33601
34058
  binaryPath: "",
33602
34059
  hwaccel: "copy"
33603
34060
  });
@@ -33656,6 +34113,7 @@ var StreamBrokerAddon = class extends BaseAddon {
33656
34113
  this.brokerManager = new StreamBrokerManager(void 0, this.ctx.logger);
33657
34114
  this.brokerManager.setDefaultPreBufferSec(this.config.defaultPreBufferSec);
33658
34115
  this.brokerManager.setDefaultJoinBurstMaxSpanMs(this.config.joinBurstMaxSpanMs);
34116
+ this.brokerManager.setDefaultPreRollMs(this.config.preRollMs);
33659
34117
  this.brokerManager.setEventBus(this.ctx.eventBus);
33660
34118
  const localNode = this.ctx.kernel.localNodeId;
33661
34119
  const localNodeId = localNode ? localNode.includes("/") ? localNode.split("/")[0] : localNode : void 0;
@@ -34191,6 +34649,17 @@ var StreamBrokerAddon = class extends BaseAddon {
34191
34649
  default: DEFAULT_PRIME_BURST_BOUND.maxSpanMs,
34192
34650
  unit: "ms"
34193
34651
  },
34652
+ {
34653
+ type: "number",
34654
+ key: "preRollMs",
34655
+ label: "Detection pre-roll",
34656
+ 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.",
34657
+ min: 0,
34658
+ max: PRE_ROLL_MAX_MS,
34659
+ step: 250,
34660
+ default: 0,
34661
+ unit: "ms"
34662
+ },
34194
34663
  {
34195
34664
  type: "number",
34196
34665
  key: "catalogReconcileIntervalSec",