@camstack/addon-pipeline 1.2.69 → 1.2.71
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.
- package/dist/audio-analyzer/index.js +1 -1
- package/dist/audio-analyzer/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +32 -30
- package/dist/detection-pipeline/index.mjs +32 -30
- package/dist/{dist-tJF2BzWi.js → dist-CtOVWTCi.js} +586 -41
- package/dist/{dist-DGLqaonv.mjs → dist-D1Xk5Tji.mjs} +586 -41
- package/dist/{event-loop-stall-monitor-D9d2e49f.js → event-loop-stall-monitor-BJs7Yws7.js} +1 -1
- package/dist/{event-loop-stall-monitor-C6_VDupx.mjs → event-loop-stall-monitor-Cmbt664E.mjs} +1 -1
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +100 -30
- package/dist/pipeline-runner/index.mjs +100 -30
- package/dist/recorder/index.js +734 -104
- package/dist/recorder/index.mjs +734 -104
- package/dist/session-decode/decode-worker-child.js +483 -52
- package/dist/session-decode/decode-worker-child.mjs +483 -52
- package/dist/stream-broker/_stub.js +660 -556
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DCyQ7RKH.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BWf4H4Nl.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Cu_kQpIy.mjs +26 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CExO0piw.mjs +26 -0
- package/dist/stream-broker/{hostInit-DxqtIjAe.mjs → hostInit-DD5Z1U6p.mjs} +3 -3
- package/dist/stream-broker/index.js +501 -32
- package/dist/stream-broker/index.mjs +501 -32
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-D6tXz9Or.js → worker-protocol-DEV7g32b.js} +26 -19
- package/dist/{worker-protocol-DuftUFEI.mjs → worker-protocol-Dm6LQ9Ls.mjs} +26 -19
- package/package.json +1 -1
- 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
- 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 +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-
|
|
6
|
+
const require_dist = require("../dist-CtOVWTCi.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
|
|
10472
|
-
* `onVideoTrack` fires (TCP up, SDP parsed)
|
|
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
|
|
10475
|
-
*
|
|
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
|
|
10491
|
-
*
|
|
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
|
-
*
|
|
10495
|
-
*
|
|
10496
|
-
*
|
|
10497
|
-
*
|
|
10498
|
-
*
|
|
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
|
|
10501
|
-
*
|
|
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
|
|
10506
|
-
*
|
|
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
|
-
|
|
11591
|
-
|
|
11592
|
-
|
|
11593
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
|
12567
|
-
|
|
12568
|
-
|
|
12904
|
+
if (this.videoRtpSeen > 0) {
|
|
12905
|
+
this.logger?.info("first-video watchdog: video flowing — standing 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
|
|
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",
|