@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.
- 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-CwUPxdAB.mjs → dist-BPaa4_z6.mjs} +525 -9
- package/dist/{dist-Bv9CqUAF.js → dist-cR-pvD_z.js} +525 -9
- package/dist/{event-loop-stall-monitor-D2dL-_bE.mjs → event-loop-stall-monitor-B79REtL2.mjs} +1 -1
- package/dist/{event-loop-stall-monitor-Bz3lx_aY.js → event-loop-stall-monitor-STRdnQWm.js} +1 -1
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +8 -5
- package/dist/pipeline-runner/index.mjs +8 -5
- package/dist/recorder/index.js +5195 -3820
- package/dist/recorder/index.mjs +5195 -3820
- package/dist/session-decode/decode-worker-child.js +1 -1
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- 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
- 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
- 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
- package/dist/stream-broker/{hostInit-_NJ0GBOO.mjs → hostInit-B12DHOv7.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-D8M43suz.js → worker-protocol-C7V1qIIA.js} +1 -1
- package/dist/{worker-protocol-0uQE5Lfd.mjs → worker-protocol-CBO8nwQj.mjs} +1 -1
- 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
|
@@ -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-
|
|
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
|
|
10466
|
-
* `onVideoTrack` fires (TCP up, SDP parsed)
|
|
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
|
|
10469
|
-
*
|
|
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
|
|
10485
|
-
*
|
|
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
|
-
*
|
|
10489
|
-
*
|
|
10490
|
-
*
|
|
10491
|
-
*
|
|
10492
|
-
*
|
|
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
|
|
10495
|
-
*
|
|
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
|
|
10500
|
-
*
|
|
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
|
-
|
|
11585
|
-
|
|
11586
|
-
|
|
11587
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
|
12561
|
-
|
|
12562
|
-
|
|
12898
|
+
if (this.videoRtpSeen > 0) {
|
|
12899
|
+
this.logger?.info("first-video watchdog: video flowing — standing 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
|
|
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",
|