@camstack/addon-pipeline 1.2.102 → 1.2.104
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/{addon-utils-CLc6yHCN.js → addon-utils-DRbRzrDy.js} +1 -1
- package/dist/audio-analyzer/index.js +3 -3
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +844 -110
- package/dist/detection-pipeline/index.mjs +842 -108
- package/dist/{dist-Ccmt3fGJ.mjs → dist-D9sltFoR.mjs} +230 -6
- package/dist/{dist-C11WuNUP.js → dist-gLAVhbvO.js} +247 -5
- package/dist/{event-loop-stall-monitor-OJrOMeuu.mjs → event-loop-stall-monitor-BS_lPX6H.mjs} +82 -88
- package/dist/{event-loop-stall-monitor-Cq_NeC4o.js → event-loop-stall-monitor-BUhYY39J.js} +82 -88
- package/dist/{lazy-sharp-RxUs6on_.js → lazy-sharp-OiAUva0g.js} +1 -1
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +8 -6
- package/dist/pipeline-runner/index.mjs +6 -4
- package/dist/{process-memory-DOjQ3MgC.js → process-memory-DY4RHcTj.js} +1 -1
- package/dist/{process-memory-D0zDmXLI.mjs → process-memory-Dw7-9jCI.mjs} +1 -1
- package/dist/recorder/index.js +660 -130
- package/dist/recorder/index.mjs +659 -129
- package/dist/remote-restream-BeHi78PZ.mjs +25 -0
- package/dist/remote-restream-CO36Sr30.js +36 -0
- package/dist/restream-intent-B4BXZra7.mjs +72 -0
- package/dist/{remote-restream-BYbAsgUf.js → restream-intent-Cv9x3jmu.js} +30 -30
- package/dist/session-decode/decode-worker-child.js +2 -2
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- package/dist/stream-broker/_stub.js +1 -1
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BdgcF1lL.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-ZaqZ17-x.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BnhMnnKV.mjs +26 -0
- package/dist/stream-broker/{hostInit-Da9wVA2r.mjs → hostInit-aZY81RnZ.mjs} +3 -3
- package/dist/stream-broker/index.js +628 -92
- package/dist/stream-broker/index.mjs +627 -91
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-DDpliBIW.mjs → worker-protocol-Bz7-sMZC.mjs} +1 -1
- package/dist/{worker-protocol-BePduZVV.js → worker-protocol-D_0q_4Le.js} +1 -1
- package/package.json +1 -1
- package/python/inference_pool.py +193 -1
- package/python/postprocessors/__init__.py +4 -0
- package/python/postprocessors/ctc.py +52 -0
- package/python/postprocessors/plate_slots.py +180 -0
- package/python/postprocessors/test_ctc.py +39 -0
- package/python/postprocessors/test_plate_slots.py +217 -0
- package/python/postprocessors/test_yolonas.py +83 -0
- package/python/postprocessors/yolonas.py +67 -0
- package/python/test_inference_pool_backpressure.py +6 -2
- package/python/test_inference_pool_coreml_cache.py +12 -4
- package/python/test_inference_pool_device_selection.py +12 -4
- package/python/test_inference_pool_layout.py +16 -5
- package/python/test_inference_pool_memstats.py +6 -2
- package/python/test_inference_pool_ov_ppp.py +12 -9
- package/python/test_inference_pool_preprocess.py +57 -0
- package/python/test_inference_pool_static_batch.py +426 -0
- package/dist/remote-restream-Ci7RXNGb.mjs +0 -66
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DY31bUCj.mjs +0 -26
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { r as __toESM, t as __commonJSMin } from "../chunk-DnnnRqeS.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { Bt as record, D as addonWidgetsSourceCapability, Dt as nodePin, Et as makeSourceBrokerId, Ft as discriminatedUnion, H as egressTransportFromRequest, Ht as union, L as deriveBatteryPresence, Lt as literal, M as cameraStreamsCapability, Mt as _enum, N as createHwAccelCache, Nt as array, Pt as boolean, Rt as number, S as RATE_CONTROL_TIGHT, St as createEvent, T as RingBuffer, Tt as makeProfileBrokerId, Ut as EventCategory, V as egressTranscodeSharingKey, Vt as string, _t as isSoftwareDecode, bt as DeviceFeature, dt as webrtcSessionCapability, ft as errMsg, gt as invocationFromEncodeProfile, ht as buildFfmpegArgs$1, i as BatteryStatusSchema, it as resolveEgressDecodeHwAccel, j as batteryCapability, kt as parseProfileBrokerId, lt as streamBrokerCapability, m as EncodeProfileSchema, mt as Fmp4BoxSplitter, pt as AUDIO_PRESETS, q as maskUrlCredentials, vt as BaseAddon, wt as isEvent, x as RATE_CONTROL_RELAXED, xt as DeviceType, yt as CAM_PROFILE_ORDER, zt as object } from "../dist-D9sltFoR.mjs";
|
|
3
|
+
import { t as parseRestreamPath } from "../restream-intent-B4BXZra7.mjs";
|
|
4
|
+
import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
|
|
4
5
|
import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DMolfhsP.mjs";
|
|
5
6
|
import { createRequire } from "node:module";
|
|
6
7
|
import * as crypto$1 from "node:crypto";
|
|
@@ -1428,6 +1429,35 @@ var TS_COPYABLE_AUDIO_CODECS = new Set(["aac"]);
|
|
|
1428
1429
|
* neither gopFrames nor fps. Matches BASE_LIVE_EGRESS_PROFILE's intent.
|
|
1429
1430
|
*/
|
|
1430
1431
|
var DERIVED_DEFAULT_GOP_FRAMES = 25;
|
|
1432
|
+
/**
|
|
1433
|
+
* No B-frames, ever, unless the profile asks for them by name.
|
|
1434
|
+
*
|
|
1435
|
+
* A derived stream's carrier is MPEG-TS → {@link MpegTsDemux} → the broker →
|
|
1436
|
+
* RTP (an RTSP restream to the recorder, a WebRTC leg to a viewer). Every hop
|
|
1437
|
+
* has to carry the reorder delay and RTP has no field for it at all — so
|
|
1438
|
+
* reordering costs latency on the whole chain and buys compression nobody
|
|
1439
|
+
* asked for at 640x360.
|
|
1440
|
+
*/
|
|
1441
|
+
var DERIVED_DEFAULT_BF = 0;
|
|
1442
|
+
/**
|
|
1443
|
+
* The encode preset and tune the carrier pins when the profile names neither.
|
|
1444
|
+
*
|
|
1445
|
+
* `veryfast` + `zerolatency` is `BASE_LIVE_EGRESS_PROFILE`'s pairing, and it is
|
|
1446
|
+
* the pairing that removes x264's lookahead: `preset medium` carries
|
|
1447
|
+
* `rc-lookahead=40`, which at 25 fps is 1.6 SECONDS the encoder holds before it
|
|
1448
|
+
* emits anything. See this module's `withCarrierDefaults` for the measurement.
|
|
1449
|
+
*/
|
|
1450
|
+
var DERIVED_DEFAULT_PRESET = "veryfast";
|
|
1451
|
+
var DERIVED_DEFAULT_TUNE = "zerolatency";
|
|
1452
|
+
/**
|
|
1453
|
+
* The bit density a derived tier is capped at, expressed as the shared
|
|
1454
|
+
* live-egress profile it is scaled from: `BASE_LIVE_EGRESS_PROFILE` spends
|
|
1455
|
+
* 2500 kbps on 1280x720 at 25 fps. Scaling that per pixel-per-second gives
|
|
1456
|
+
* 625 kbps at 640x360@25 — and a 10 s segment of 625 kbps is ~780 KB, half of
|
|
1457
|
+
* the scrub's 1.5 MB whole-segment ceiling (D163).
|
|
1458
|
+
*/
|
|
1459
|
+
var DERIVED_BITRATE_REFERENCE_KBPS = 2500;
|
|
1460
|
+
var DERIVED_BITRATE_REFERENCE_PIXELS_PER_SECOND = 1280 * 720 * 25;
|
|
1431
1461
|
var DERIVED_AAC_RECODE = {
|
|
1432
1462
|
codec: "aac",
|
|
1433
1463
|
channels: 1
|
|
@@ -1481,12 +1511,66 @@ function resolveDerivedAudio(profileAudio, sourceAudioCodec) {
|
|
|
1481
1511
|
* own — the MPEG-TS carrier, not the operator, decides whether the source
|
|
1482
1512
|
* track can be copied.
|
|
1483
1513
|
*/
|
|
1514
|
+
/**
|
|
1515
|
+
* The rate ceiling for a geometry, or `undefined` when the profile named no
|
|
1516
|
+
* geometry to scale by (there is then nothing to derive a number from, and a
|
|
1517
|
+
* guessed cap on an unknown resolution is worse than none).
|
|
1518
|
+
*/
|
|
1519
|
+
function derivedBitrateCeilingKbps(video) {
|
|
1520
|
+
const { width, height } = video;
|
|
1521
|
+
if (width === void 0 || height === void 0) return void 0;
|
|
1522
|
+
const pixelsPerSecond = width * height * (video.fps ?? DERIVED_DEFAULT_GOP_FRAMES);
|
|
1523
|
+
return Math.round(DERIVED_BITRATE_REFERENCE_KBPS * pixelsPerSecond / DERIVED_BITRATE_REFERENCE_PIXELS_PER_SECOND);
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Fill the encode knobs the CARRIER owns, leaving every one the profile names
|
|
1527
|
+
* untouched. Same rule as the audio decision above: an operator states intent,
|
|
1528
|
+
* the carrier states what its own hops can survive.
|
|
1529
|
+
*
|
|
1530
|
+
* - **GOP.** x264's own default is keyint=250 — TEN seconds at the fleet's
|
|
1531
|
+
* 25 fps. Measured live (camera dispensa, 2026-08-19): 1 I-frame in 250 on a
|
|
1532
|
+
* recorded low segment, and a WebRTC viewer joining mid-GOP decoded
|
|
1533
|
+
* reference-less P-frames for up to 10 s — the smear artifacts on every live
|
|
1534
|
+
* open of a derived tier, grid tiles included.
|
|
1535
|
+
*
|
|
1536
|
+
* - **B-frames, preset, tune.** Measured live (camera 1438, 2026-08-20) by
|
|
1537
|
+
* reading the camera's burnt-in OSD out of RECORDED segments and comparing
|
|
1538
|
+
* it against the wall clock the recorder named the file with. Same camera,
|
|
1539
|
+
* so the same OSD clock; mid-roll samples only, because the first segment of
|
|
1540
|
+
* a triggered roll carries pre-roll and is older than its own name:
|
|
1541
|
+
*
|
|
1542
|
+
* native `high` (-c:v copy): -1s -1s -1s -2s
|
|
1543
|
+
* derived `low` (transcode): -3s -3s -3s -3s
|
|
1544
|
+
*
|
|
1545
|
+
* ~2 s, and it is the encoder holding frames back: `preset medium` carries
|
|
1546
|
+
* `rc-lookahead=40` (1.6 s at 25 fps) on top of `bframes=3` with b-adapt and
|
|
1547
|
+
* b-pyramid. The recorder stamps a segment with the wall clock at WRITE
|
|
1548
|
+
* time, so all of it becomes a MISLABEL: seeking a derived tier to an
|
|
1549
|
+
* event's timestamp lands ~2 s past the event while the native tier of the
|
|
1550
|
+
* same camera lands on it.
|
|
1551
|
+
*
|
|
1552
|
+
* - **Bitrate.** A CEILING (`-maxrate`, no `-b:v` — see `buildRateControlArgs`,
|
|
1553
|
+
* whose default rate control is `cap`), because a derived tier is RECORDED
|
|
1554
|
+
* and the scrub reads a `low` segment whole only while it is under 1.5 MB
|
|
1555
|
+
* (D163). Uncapped CRF has no relationship to that ceiling: 1438/low
|
|
1556
|
+
* measured 537–941 kbps at 640x360@25 against 142 kbps for camera 615's
|
|
1557
|
+
* NATIVE low at the identical geometry, peaking at 1.19 MB in a 10 s
|
|
1558
|
+
* segment. Quiet scenes still spend less than the cap.
|
|
1559
|
+
*/
|
|
1560
|
+
function withCarrierDefaults(video) {
|
|
1561
|
+
const bitrateKbps = video.bitrateKbps ?? derivedBitrateCeilingKbps(video);
|
|
1562
|
+
return {
|
|
1563
|
+
...video,
|
|
1564
|
+
gopFrames: video.gopFrames ?? video.fps ?? DERIVED_DEFAULT_GOP_FRAMES,
|
|
1565
|
+
bf: video.bf ?? DERIVED_DEFAULT_BF,
|
|
1566
|
+
preset: video.preset ?? DERIVED_DEFAULT_PRESET,
|
|
1567
|
+
tune: video.tune ?? DERIVED_DEFAULT_TUNE,
|
|
1568
|
+
...bitrateKbps !== void 0 ? { bitrateKbps } : {}
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1484
1571
|
function buildDerivedTranscodeArgs(input) {
|
|
1485
1572
|
const audio = resolveDerivedAudio(input.profile.audio, input.sourceAudioCodec);
|
|
1486
|
-
const video = input.profile.video
|
|
1487
|
-
...input.profile.video,
|
|
1488
|
-
gopFrames: input.profile.video.fps ?? DERIVED_DEFAULT_GOP_FRAMES
|
|
1489
|
-
};
|
|
1573
|
+
const video = withCarrierDefaults(input.profile.video);
|
|
1490
1574
|
return {
|
|
1491
1575
|
args: buildFfmpegArgs$1(invocationFromEncodeProfile({
|
|
1492
1576
|
profile: {
|
|
@@ -1552,11 +1636,34 @@ var NOTHING = {
|
|
|
1552
1636
|
ageMs: 0,
|
|
1553
1637
|
gopsBack: 0
|
|
1554
1638
|
};
|
|
1555
|
-
/**
|
|
1556
|
-
|
|
1639
|
+
/**
|
|
1640
|
+
* A configured pre-roll in ms, or `0` when the input carries no instruction.
|
|
1641
|
+
*
|
|
1642
|
+
* `maxMs` is the ceiling of the WINDOW being resolved: detection's
|
|
1643
|
+
* ({@link PRE_ROLL_MAX_MS}) bounds a decoder's drain, recording's
|
|
1644
|
+
* ({@link RECORDING_PRE_ROLL_MAX_MS}) bounds the recorder's keep gate. One
|
|
1645
|
+
* clamp for both so a new window cannot arrive with its own rounding rules.
|
|
1646
|
+
*/
|
|
1647
|
+
function clampPreRoll(value, maxMs) {
|
|
1557
1648
|
if (value === void 0) return 0;
|
|
1558
1649
|
if (!Number.isFinite(value) || value <= 0) return 0;
|
|
1559
|
-
return Math.min(
|
|
1650
|
+
return Math.min(maxMs, Math.max(250, value));
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* {@link clampPreRoll} at the WIDEST ceiling any window in this system may
|
|
1654
|
+
* carry.
|
|
1655
|
+
*
|
|
1656
|
+
* Used by the three functions that operate on a window ALREADY resolved by
|
|
1657
|
+
* `resolvePreRollMs` / `resolveRecordingPreRollMs` — the selection, the packet
|
|
1658
|
+
* ceiling and the span bound. Clamping those at the DETECTION ceiling was
|
|
1659
|
+
* correct while there was one window and became a silent 6 s truncation of
|
|
1660
|
+
* every recording burst the moment there were two: the selector would refuse
|
|
1661
|
+
* the keyframe the recorder asked for, and the span bound would withhold the
|
|
1662
|
+
* burst for being as wide as the operator configured — D192's failure, one
|
|
1663
|
+
* layer down.
|
|
1664
|
+
*/
|
|
1665
|
+
function usablePreRoll(value) {
|
|
1666
|
+
return clampPreRoll(value, RECORDING_PRE_ROLL_MAX_MS);
|
|
1560
1667
|
}
|
|
1561
1668
|
/**
|
|
1562
1669
|
* Choose the keyframe access unit a pre-roll burst starts at.
|
|
@@ -1594,6 +1701,104 @@ function selectPreRollStart(marks, preRollMs) {
|
|
|
1594
1701
|
};
|
|
1595
1702
|
}
|
|
1596
1703
|
/**
|
|
1704
|
+
* Ceiling for the RECORDING window.
|
|
1705
|
+
*
|
|
1706
|
+
* Wider than {@link PRE_ROLL_MAX_MS} because the two windows are bounded by
|
|
1707
|
+
* different things. Detection's ceiling is the backlog a decoder must drain
|
|
1708
|
+
* before it reaches the live edge — media the analyser pays for frame by frame.
|
|
1709
|
+
* A recorder drains nothing: ffmpeg copies the burst to disk. What bounds THIS
|
|
1710
|
+
* number is the recorder's retroactive keep window (`preBufferSec`), because
|
|
1711
|
+
* footage the broker hands the writer from before that bound is deleted by the
|
|
1712
|
+
* keep gate the moment it finalizes — the divergence D191/D192 exist to forbid.
|
|
1713
|
+
* `packages/addon-pipeline/src/recorder/addon/__tests__/band-decision.spec.ts`
|
|
1714
|
+
* fails if this is ever raised past the shipped keep window.
|
|
1715
|
+
*/
|
|
1716
|
+
var RECORDING_PRE_ROLL_MAX_MS = 15e3;
|
|
1717
|
+
/**
|
|
1718
|
+
* Shipped RECORDING pre-roll on a mains camera. Operator decision, 2026-08-20.
|
|
1719
|
+
*
|
|
1720
|
+
* Unlike detection's, this one ships ON. The audit of the same day measured the
|
|
1721
|
+
* reason: on camera 1438 the footage began AFTER the motion in 60 % of
|
|
1722
|
+
* episodes, because the writer is spawned at the trigger and ffmpeg's first
|
|
1723
|
+
* frame is at best the live edge. A recording feature whose media starts after
|
|
1724
|
+
* the event is not a tuning question.
|
|
1725
|
+
*/
|
|
1726
|
+
var DEFAULT_RECORDING_PRE_ROLL_MS = 1e4;
|
|
1727
|
+
/**
|
|
1728
|
+
* Budget when the broker has not measured a bitrate for this stream yet — the
|
|
1729
|
+
* historical flat cap, kept verbatim as the honest "we do not know".
|
|
1730
|
+
*
|
|
1731
|
+
* `bitrateKbps` is 0 for the first stats window after every dial and after
|
|
1732
|
+
* every reset, so this is the value in force exactly while the ring is filling
|
|
1733
|
+
* for the first time.
|
|
1734
|
+
*/
|
|
1735
|
+
var PRE_ROLL_UNKNOWN_BITRATE_BYTES = 8 * 1024 * 1024;
|
|
1736
|
+
/**
|
|
1737
|
+
* Absolute per-stream ceiling on retained pre-roll history.
|
|
1738
|
+
*
|
|
1739
|
+
* The bitrate-derived budget is what the window COSTS; this is the most any one
|
|
1740
|
+
* stream may spend regardless. 32 MiB is 4x the flat cap it replaces and covers
|
|
1741
|
+
* 15 s of a 16 Mbps stream with headroom; past it the operator has configured a
|
|
1742
|
+
* window this node cannot afford on that camera, and the broker says so rather
|
|
1743
|
+
* than delivering silently less (D192).
|
|
1744
|
+
*/
|
|
1745
|
+
var PRE_ROLL_ABSOLUTE_MAX_BYTES = 32 * 1024 * 1024;
|
|
1746
|
+
/**
|
|
1747
|
+
* Headroom over the OBSERVED mean bitrate.
|
|
1748
|
+
*
|
|
1749
|
+
* `BrokerStatsSchema.bitrateKbps` is a mean over the stats window; a GOP
|
|
1750
|
+
* carrying an IDR plus a scene change is well above it. Budgeting at exactly
|
|
1751
|
+
* the mean would drop the oldest GOP on every busy second, so the window would
|
|
1752
|
+
* be the one the operator asked for only while nothing moved — which is the
|
|
1753
|
+
* opposite of when it is needed.
|
|
1754
|
+
*/
|
|
1755
|
+
var PRE_ROLL_BITRATE_HEADROOM = 1.5;
|
|
1756
|
+
/**
|
|
1757
|
+
* The byte budget for the history a `windowMs` pre-roll needs on a stream
|
|
1758
|
+
* observed at `observedKbps`.
|
|
1759
|
+
*
|
|
1760
|
+
* Replaces a FLAT 8 MiB cap. That constant was a promise the feature could not
|
|
1761
|
+
* keep in either direction: above ~6.5 Mbps it silently truncated the window
|
|
1762
|
+
* (a 4K main stream asked for 10 s and got 6), and below it, it authorised a
|
|
1763
|
+
* doorbell at 512 kbps to hold 8 MiB — 16x what its own window weighs.
|
|
1764
|
+
*/
|
|
1765
|
+
function preRollBudgetBytes(windowMs, observedKbps) {
|
|
1766
|
+
if (!Number.isFinite(windowMs) || windowMs <= 0) return {
|
|
1767
|
+
bytes: 0,
|
|
1768
|
+
clamped: false
|
|
1769
|
+
};
|
|
1770
|
+
if (!Number.isFinite(observedKbps) || observedKbps <= 0) return {
|
|
1771
|
+
bytes: PRE_ROLL_UNKNOWN_BITRATE_BYTES,
|
|
1772
|
+
clamped: false
|
|
1773
|
+
};
|
|
1774
|
+
const raw = Math.round(windowMs * observedKbps * PRE_ROLL_BITRATE_HEADROOM / 8);
|
|
1775
|
+
if (raw > 33554432) return {
|
|
1776
|
+
bytes: PRE_ROLL_ABSOLUTE_MAX_BYTES,
|
|
1777
|
+
clamped: true
|
|
1778
|
+
};
|
|
1779
|
+
return {
|
|
1780
|
+
bytes: raw,
|
|
1781
|
+
clamped: false
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
/**
|
|
1785
|
+
* The RECORDING window a broker retains and serves to a `/recording` dial.
|
|
1786
|
+
*
|
|
1787
|
+
* Precedence: stream override -> battery / terminal relay (0) -> cluster
|
|
1788
|
+
* default -> {@link DEFAULT_RECORDING_PRE_ROLL_MS}. An override wins over the
|
|
1789
|
+
* battery rule on purpose: the rule is a DEFAULT about where retention is
|
|
1790
|
+
* wasted, and an operator who typed a number for that stream has answered the
|
|
1791
|
+
* question the default was guessing at.
|
|
1792
|
+
*/
|
|
1793
|
+
function resolveRecordingPreRollMs(input) {
|
|
1794
|
+
const override = clampPreRoll(input.overrides?.[input.camStreamId], RECORDING_PRE_ROLL_MAX_MS);
|
|
1795
|
+
if (override > 0) return override;
|
|
1796
|
+
if (input.batteryOperated === true) return 0;
|
|
1797
|
+
if (input.terminalRelay === true) return 0;
|
|
1798
|
+
if (input.clusterMs === void 0) return DEFAULT_RECORDING_PRE_ROLL_MS;
|
|
1799
|
+
return clampPreRoll(input.clusterMs, RECORDING_PRE_ROLL_MAX_MS);
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1597
1802
|
* Combine the cluster default and the per-camera-stream override into the one
|
|
1598
1803
|
* pre-roll a broker applies. Precedence: stream override → cluster default →
|
|
1599
1804
|
* OFF. Each layer is validated independently, so a bad override falls back to a
|
|
@@ -1604,10 +1809,10 @@ function selectPreRollStart(marks, preRollMs) {
|
|
|
1604
1809
|
* coincide on every unconfigured stream.
|
|
1605
1810
|
*/
|
|
1606
1811
|
function resolvePreRollMs(input) {
|
|
1607
|
-
const override =
|
|
1812
|
+
const override = clampPreRoll(input.overrides?.[input.camStreamId], PRE_ROLL_MAX_MS);
|
|
1608
1813
|
if (override > 0) return override;
|
|
1609
1814
|
if (input.terminalRelay === true) return 0;
|
|
1610
|
-
return
|
|
1815
|
+
return clampPreRoll(input.clusterMs, PRE_ROLL_MAX_MS);
|
|
1611
1816
|
}
|
|
1612
1817
|
/**
|
|
1613
1818
|
* The packet ceiling for a burst carrying `preRollMs` of pre-roll.
|
|
@@ -2277,8 +2482,13 @@ var RtspRestreamer = class {
|
|
|
2277
2482
|
* A second supplier and not a widening of {@link rtpPreBufferSupplier},
|
|
2278
2483
|
* because the two feed different populations: that one is also the WebRTC
|
|
2279
2484
|
* bootstrap and every ordinary RTSP join, and handing them history is the
|
|
2280
|
-
* D57 failure. Only a session that
|
|
2281
|
-
*
|
|
2485
|
+
* D57 failure. Only a session that DECLARED an intent on its dial path is
|
|
2486
|
+
* served from this one.
|
|
2487
|
+
*
|
|
2488
|
+
* Takes the intent, because the two windows are two numbers (D193): a
|
|
2489
|
+
* detection dial drains its backlog frame by frame and a recorder copies it
|
|
2490
|
+
* to disk, so the operator sets them apart. One supplier keyed by intent, and
|
|
2491
|
+
* not two suppliers, so a third intent cannot arrive with its own wiring.
|
|
2282
2492
|
*
|
|
2283
2493
|
* Pulled per join for the same reason as the bound: a broker outlives the
|
|
2284
2494
|
* setting.
|
|
@@ -2572,36 +2782,74 @@ var RtspRestreamer = class {
|
|
|
2572
2782
|
if (this.sessions.get(sessionId)?.isPlaying()) ready.push(sessionId);
|
|
2573
2783
|
}
|
|
2574
2784
|
if (ready.length === 0) return;
|
|
2575
|
-
const
|
|
2576
|
-
const preRollPackets = fromRing && preRoll && preRoll.preRollMs > 0 && preRoll.packets.length > primePackets.length ? preRoll.packets.map((data) => ({
|
|
2577
|
-
data,
|
|
2578
|
-
marker: (data[1] & 128) !== 0
|
|
2579
|
-
})) : [];
|
|
2580
|
-
const preRollActive = preRollPackets.length > 0;
|
|
2581
|
-
const preRollSpanMs = preRollActive ? (preRollPackets[preRollPackets.length - 1].data.readUInt32BE(4) - preRollPackets[0].data.readUInt32BE(4) | 0) / 90 : 0;
|
|
2785
|
+
const preRollPlans = /* @__PURE__ */ new Map();
|
|
2582
2786
|
const bound = this.currentPrimeBound();
|
|
2583
2787
|
const overSpan = spanMs > bound.maxSpanMs;
|
|
2584
2788
|
const overPackets = primePackets.length > bound.maxPackets;
|
|
2585
|
-
|
|
2586
|
-
|
|
2789
|
+
/**
|
|
2790
|
+
* Resolve (and memoize) the plan for one intent.
|
|
2791
|
+
*
|
|
2792
|
+
* The bound a PRE-ROLL burst is held to is D57's, widened to the pre-roll
|
|
2793
|
+
* the operator configured FOR THAT INTENT, with the packet ceiling scaled
|
|
2794
|
+
* by the same factor so the defensive backstop stays a rate. A window WIDER
|
|
2795
|
+
* than that is still withheld — the selector cannot produce one, but a long
|
|
2796
|
+
* current GOP or a supplier bug can, and D57's remedy (join clean at the
|
|
2797
|
+
* next live keyframe) is the right one for a burst nobody asked for.
|
|
2798
|
+
*/
|
|
2799
|
+
const planFor = (intent) => {
|
|
2800
|
+
const cached = preRollPlans.get(intent);
|
|
2801
|
+
if (cached) return cached;
|
|
2802
|
+
const burst = this.preRollSupplier?.(intent);
|
|
2803
|
+
const window = fromRing && burst && burst.preRollMs > 0 ? burst.packets() : [];
|
|
2804
|
+
const packets = window.length > primePackets.length ? window.map((data) => ({
|
|
2805
|
+
data,
|
|
2806
|
+
marker: (data[1] & 128) !== 0
|
|
2807
|
+
})) : [];
|
|
2808
|
+
const windowSpanMs = packets.length > 0 ? (packets[packets.length - 1].data.readUInt32BE(4) - packets[0].data.readUInt32BE(4) | 0) / 90 : 0;
|
|
2809
|
+
const windowBound = packets.length > 0 ? preRollSpanBound(bound, burst?.preRollMs ?? 0) : bound;
|
|
2810
|
+
const plan = {
|
|
2811
|
+
packets,
|
|
2812
|
+
active: packets.length > 0,
|
|
2813
|
+
configuredMs: burst?.preRollMs ?? 0,
|
|
2814
|
+
spanMs: windowSpanMs,
|
|
2815
|
+
overBound: packets.length > 0 && (windowSpanMs > windowBound.maxSpanMs || packets.length > windowBound.maxPackets)
|
|
2816
|
+
};
|
|
2817
|
+
preRollPlans.set(intent, plan);
|
|
2818
|
+
return plan;
|
|
2819
|
+
};
|
|
2587
2820
|
const served = [];
|
|
2588
|
-
const servedPreRoll = /* @__PURE__ */ new
|
|
2821
|
+
const servedPreRoll = /* @__PURE__ */ new Map();
|
|
2589
2822
|
let withheld = 0;
|
|
2590
2823
|
let detectionIntentServed = 0;
|
|
2591
2824
|
for (const sessionId of ready) {
|
|
2592
2825
|
const session = this.sessions.get(sessionId);
|
|
2593
2826
|
if (!session) continue;
|
|
2594
2827
|
const detectionIntent = session.isDetectionIntent();
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2828
|
+
const preRollIntent = session.preRollIntent();
|
|
2829
|
+
if (preRollIntent !== null) {
|
|
2830
|
+
const plan = planFor(preRollIntent);
|
|
2831
|
+
if (plan.active) {
|
|
2832
|
+
if (plan.overBound) {
|
|
2833
|
+
this.dropsWithheldSpan++;
|
|
2834
|
+
withheld++;
|
|
2835
|
+
continue;
|
|
2836
|
+
}
|
|
2837
|
+
servedPreRoll.set(sessionId, preRollIntent);
|
|
2838
|
+
served.push(sessionId);
|
|
2839
|
+
this.logger?.info("restream pre-roll served", {
|
|
2840
|
+
tags: { deviceId: this.deviceId },
|
|
2841
|
+
meta: {
|
|
2842
|
+
streamName: this.streamName,
|
|
2843
|
+
sessionId,
|
|
2844
|
+
intent: preRollIntent,
|
|
2845
|
+
consumer: session.getUserAgent() ?? "unknown",
|
|
2846
|
+
configuredMs: plan.configuredMs,
|
|
2847
|
+
spanMs: Math.round(plan.spanMs),
|
|
2848
|
+
packets: plan.packets.length
|
|
2849
|
+
}
|
|
2850
|
+
});
|
|
2599
2851
|
continue;
|
|
2600
2852
|
}
|
|
2601
|
-
detectionIntentServed++;
|
|
2602
|
-
servedPreRoll.add(sessionId);
|
|
2603
|
-
served.push(sessionId);
|
|
2604
|
-
continue;
|
|
2605
2853
|
}
|
|
2606
2854
|
if (session.isMuted() && !detectionIntent) {
|
|
2607
2855
|
if (!fromRing || spanMs > LIVE_EDGE_MAX_SPAN_MS) {
|
|
@@ -2620,6 +2868,11 @@ var RtspRestreamer = class {
|
|
|
2620
2868
|
}
|
|
2621
2869
|
if (withheld > 0) this.reportDrops(false);
|
|
2622
2870
|
if (served.length === 0) return;
|
|
2871
|
+
/** Media actually delivered to this intent, or `-1` when none joined. */
|
|
2872
|
+
const servedSpanMs = (intent) => {
|
|
2873
|
+
const plan = preRollPlans.get(intent);
|
|
2874
|
+
return plan === void 0 ? -1 : Math.round(plan.spanMs);
|
|
2875
|
+
};
|
|
2623
2876
|
this.logger?.info("restream prime (lat-probe)", {
|
|
2624
2877
|
tags: { deviceId: this.deviceId },
|
|
2625
2878
|
meta: {
|
|
@@ -2632,9 +2885,10 @@ var RtspRestreamer = class {
|
|
|
2632
2885
|
boundPackets: bound.maxPackets,
|
|
2633
2886
|
withheld,
|
|
2634
2887
|
detectionIntentServed,
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2888
|
+
detectionPreRollMs: this.preRollSupplier?.("detection").preRollMs ?? 0,
|
|
2889
|
+
recordingPreRollMs: this.preRollSupplier?.("recording").preRollMs ?? 0,
|
|
2890
|
+
detectionPreRollSpanMs: servedSpanMs("detection"),
|
|
2891
|
+
recordingPreRollSpanMs: servedSpanMs("recording"),
|
|
2638
2892
|
preRollServed: servedPreRoll.size
|
|
2639
2893
|
}
|
|
2640
2894
|
});
|
|
@@ -2643,14 +2897,16 @@ var RtspRestreamer = class {
|
|
|
2643
2897
|
prime: true,
|
|
2644
2898
|
keyframeAuStart: false
|
|
2645
2899
|
}));
|
|
2646
|
-
const preRollItems =
|
|
2900
|
+
const preRollItems = /* @__PURE__ */ new Map();
|
|
2901
|
+
for (const intent of new Set(servedPreRoll.values())) preRollItems.set(intent, (preRollPlans.get(intent)?.packets ?? []).map((packet) => ({
|
|
2647
2902
|
packet,
|
|
2648
2903
|
prime: true,
|
|
2649
2904
|
keyframeAuStart: false
|
|
2650
|
-
}))
|
|
2905
|
+
})));
|
|
2651
2906
|
for (const sessionId of served) {
|
|
2652
2907
|
this.pendingKeyframe.delete(sessionId);
|
|
2653
|
-
|
|
2908
|
+
const intent = servedPreRoll.get(sessionId);
|
|
2909
|
+
this.startPrimeReplay(sessionId, intent ? preRollItems.get(intent) ?? items : items, intent ?? null);
|
|
2654
2910
|
}
|
|
2655
2911
|
}
|
|
2656
2912
|
/**
|
|
@@ -2663,7 +2919,7 @@ var RtspRestreamer = class {
|
|
|
2663
2919
|
* session that went away — aborts the whole replay rather than punching holes
|
|
2664
2920
|
* in the GOP: the session is left latched and rejoins at the next keyframe AU.
|
|
2665
2921
|
*/
|
|
2666
|
-
startPrimeReplay(sessionId, items) {
|
|
2922
|
+
startPrimeReplay(sessionId, items, preRollIntent) {
|
|
2667
2923
|
const replay = new PacedRtpReplay((item) => {
|
|
2668
2924
|
const session = this.sessions.get(sessionId);
|
|
2669
2925
|
if (!session) {
|
|
@@ -2679,6 +2935,20 @@ var RtspRestreamer = class {
|
|
|
2679
2935
|
return false;
|
|
2680
2936
|
}, (stats) => {
|
|
2681
2937
|
this.activeReplays.delete(sessionId);
|
|
2938
|
+
if (preRollIntent !== null) this.logger?.info("restream pre-roll replay done", {
|
|
2939
|
+
tags: { deviceId: this.deviceId },
|
|
2940
|
+
meta: {
|
|
2941
|
+
streamName: this.streamName,
|
|
2942
|
+
sessionId,
|
|
2943
|
+
intent: preRollIntent,
|
|
2944
|
+
consumer: this.sessions.get(sessionId)?.getUserAgent() ?? "unknown",
|
|
2945
|
+
packets: stats.packets,
|
|
2946
|
+
sent: stats.sent,
|
|
2947
|
+
aborted: stats.aborted,
|
|
2948
|
+
enqueuedLive: stats.enqueuedLive,
|
|
2949
|
+
durationMs: stats.durationMs
|
|
2950
|
+
}
|
|
2951
|
+
});
|
|
2682
2952
|
this.logger?.debug("restream prime drained", {
|
|
2683
2953
|
tags: { deviceId: this.deviceId },
|
|
2684
2954
|
meta: {
|
|
@@ -3027,6 +3297,17 @@ var RtspSession = class {
|
|
|
3027
3297
|
return this.muted;
|
|
3028
3298
|
}
|
|
3029
3299
|
/**
|
|
3300
|
+
* The `User-Agent` this client put on its first request, or `null`.
|
|
3301
|
+
*
|
|
3302
|
+
* The ONLY identity that crosses from a consumer process into this session.
|
|
3303
|
+
* The restreamer prints it beside the pre-roll window it serves, which is how
|
|
3304
|
+
* a burst the broker measured can be tied to the file the consumer wrote —
|
|
3305
|
+
* `sessionId` is minted here and appears in no consumer's logs.
|
|
3306
|
+
*/
|
|
3307
|
+
getUserAgent() {
|
|
3308
|
+
return this.userAgent;
|
|
3309
|
+
}
|
|
3310
|
+
/**
|
|
3030
3311
|
* True only for a session that dialed the DETECTION path
|
|
3031
3312
|
* (`DETECTION_MUTED_PATH_SUFFIX`). Read by
|
|
3032
3313
|
* `RtspRestreamer.servePendingSessionsRtp` to exempt this session from the
|
|
@@ -3037,6 +3318,28 @@ var RtspSession = class {
|
|
|
3037
3318
|
return this.intent === "detection";
|
|
3038
3319
|
}
|
|
3039
3320
|
/**
|
|
3321
|
+
* Does this session get the PRE-ROLL window instead of the current GOP?
|
|
3322
|
+
*
|
|
3323
|
+
* Both declared intents do, for the same reason: they attach after the edge
|
|
3324
|
+
* they need to see. Separate from {@link isDetectionIntent} because that one
|
|
3325
|
+
* also lifts the live-edge withhold, which is a MUTED-consumer rule and must
|
|
3326
|
+
* not be lifted for anyone else.
|
|
3327
|
+
*/
|
|
3328
|
+
wantsPreRoll() {
|
|
3329
|
+
return this.preRollIntent() !== null;
|
|
3330
|
+
}
|
|
3331
|
+
/**
|
|
3332
|
+
* WHICH pre-roll window this session gets, or `null` when it declared no
|
|
3333
|
+
* intent and takes the ordinary current-GOP join burst.
|
|
3334
|
+
*
|
|
3335
|
+
* The two windows are two numbers (D193), so the restreamer needs the intent
|
|
3336
|
+
* and not just the bit: a recorder handed detection's window would carry the
|
|
3337
|
+
* pre-roll of a feature it is not, and on this fleet detection's is 0.
|
|
3338
|
+
*/
|
|
3339
|
+
preRollIntent() {
|
|
3340
|
+
return this.intent === "detection" || this.intent === "recording" ? this.intent : null;
|
|
3341
|
+
}
|
|
3342
|
+
/**
|
|
3040
3343
|
* Diagnostic snapshot consumed by `StreamBroker.listClients` (Phase 10).
|
|
3041
3344
|
* Gives operators the information needed to identify a specific RTSP
|
|
3042
3345
|
* session: where it's coming from, whether it's active, when it
|
|
@@ -11026,6 +11329,8 @@ var StreamBroker = class StreamBroker {
|
|
|
11026
11329
|
* isn't useful. 500 ms is wider than the worst-case fan-out delay.
|
|
11027
11330
|
*/
|
|
11028
11331
|
lastRefreshRequestAt = 0;
|
|
11332
|
+
/** When {@link kickReconnect} last acted, for its own throttle. */
|
|
11333
|
+
lastKickAt = 0;
|
|
11029
11334
|
/** Detected or configured codec — persists across reconnects */
|
|
11030
11335
|
detectedCodec;
|
|
11031
11336
|
/**
|
|
@@ -11163,6 +11468,58 @@ var StreamBroker = class StreamBroker {
|
|
|
11163
11468
|
*/
|
|
11164
11469
|
static FIRST_VIDEO_TIMEOUT_MS = 8e3;
|
|
11165
11470
|
/**
|
|
11471
|
+
* The first-video deadline for a dial that FOLLOWED a lazy materialize.
|
|
11472
|
+
*
|
|
11473
|
+
* 8 s was sized against Chrome's ~15 s ICE-disconnect: detect a stuck stream
|
|
11474
|
+
* before the viewer's browser gives up. That reasoning holds for a source
|
|
11475
|
+
* that is already open and has stalled. It is exactly wrong for a COLD open
|
|
11476
|
+
* behind a lazy publisher.
|
|
11477
|
+
*
|
|
11478
|
+
* Measured on the wyze bridge (devices 3838/3836, 2026-08-20): a successful
|
|
11479
|
+
* open costs **12.4 s** — `WyzeRfc4571Server` waits for the first keyframe
|
|
11480
|
+
* with a 10 s ceiling, on top of ~2 s of DTLS handshake. At 8 s the watchdog
|
|
11481
|
+
* tore down a connection that was two seconds from delivering, and the cost
|
|
11482
|
+
* of that teardown is not "try again quickly": the bridge closes its P2P
|
|
11483
|
+
* server 30 s after the last client, so the next dial repays the whole cold
|
|
11484
|
+
* open. Chrome had given up either way; the only thing the short deadline
|
|
11485
|
+
* changed was whether the NEXT viewer paid for it again too.
|
|
11486
|
+
*
|
|
11487
|
+
* 20 s covers the measured open with margin and still fires well inside the
|
|
11488
|
+
* activity watchdog's own recovery path for a connection that is genuinely
|
|
11489
|
+
* dead.
|
|
11490
|
+
*/
|
|
11491
|
+
static LAZY_MATERIALIZE_FIRST_VIDEO_TIMEOUT_MS = 2e4;
|
|
11492
|
+
/**
|
|
11493
|
+
* How long after a lazy materialize was REQUESTED a dial still counts as
|
|
11494
|
+
* "this one is the cold open we asked for". Past it the source is an
|
|
11495
|
+
* ordinary open and takes the ordinary deadline.
|
|
11496
|
+
*/
|
|
11497
|
+
static MATERIALIZE_RECENT_MS = 3e4;
|
|
11498
|
+
/**
|
|
11499
|
+
* Poll cadence while a lazy publisher is materializing its upstream.
|
|
11500
|
+
*
|
|
11501
|
+
* The exponential backoff is the right shape for a source that is FAILING
|
|
11502
|
+
* and the wrong one for a source that is being built: the publisher answers
|
|
11503
|
+
* in ~12 s, and 1-2-4-8-16 s means the broker learns about it at 16 — up to
|
|
11504
|
+
* 4 s of pure quantisation on every wyze cold open. Waiting at a flat 1 s
|
|
11505
|
+
* costs one cheap re-resolve per second against a bounded deadline.
|
|
11506
|
+
*/
|
|
11507
|
+
static MATERIALIZE_POLL_MS = 1e3;
|
|
11508
|
+
/**
|
|
11509
|
+
* Bound on the flat-poll wait. Past it the materialize is not slow, it has
|
|
11510
|
+
* failed, and the ordinary exponential backoff is the right answer again.
|
|
11511
|
+
* Sized at the measured open (12.4 s) plus room for one retry inside the
|
|
11512
|
+
* publisher.
|
|
11513
|
+
*/
|
|
11514
|
+
static MATERIALIZE_WAIT_MS = 2e4;
|
|
11515
|
+
/**
|
|
11516
|
+
* When the current lazy materialize was first requested, or `0` when none is
|
|
11517
|
+
* outstanding. Drives BOTH the flat poll above and the extended first-video
|
|
11518
|
+
* deadline — one fact, read in two places, so a materialize cannot be "in
|
|
11519
|
+
* progress" for the dial loop and "not happening" for the watchdog.
|
|
11520
|
+
*/
|
|
11521
|
+
materializeRequestedAt = 0;
|
|
11522
|
+
/**
|
|
11166
11523
|
* Rolling source-RTP activity watchdog. Distinct from the first-video
|
|
11167
11524
|
* watchdog: this one ARMS at DIAL time (so a connection that opens the
|
|
11168
11525
|
* loopback TCP but never delivers media is still detected) and RESETS on
|
|
@@ -11334,7 +11691,21 @@ var StreamBroker = class StreamBroker {
|
|
|
11334
11691
|
* then behaves exactly as it did before pre-roll existed. Resolved per stream
|
|
11335
11692
|
* by `resolvePreRollMs` and pushed by the manager.
|
|
11336
11693
|
*/
|
|
11337
|
-
|
|
11694
|
+
detectionPreRollMs = 0;
|
|
11695
|
+
/**
|
|
11696
|
+
* Media time (ms) of history a RECORDING dial (`/recording`) is handed — the
|
|
11697
|
+
* `events`-band recorder's ffmpeg, which is spawned AT the trigger and whose
|
|
11698
|
+
* first frame is otherwise at best the live edge.
|
|
11699
|
+
*
|
|
11700
|
+
* A SECOND number, not a wider first one (D193). The two windows are set by
|
|
11701
|
+
* the operator for different reasons and cost different things: detection's
|
|
11702
|
+
* is a backlog a decoder must drain, recording's is bytes copied to disk.
|
|
11703
|
+
* Raising one to serve the other would change every detection dial on the
|
|
11704
|
+
* fleet to buy a recorder ten seconds — the coupling the 2026-08-20 census
|
|
11705
|
+
* named before anything was turned on. Resolved by `resolveRecordingPreRollMs`
|
|
11706
|
+
* and pushed by the manager.
|
|
11707
|
+
*/
|
|
11708
|
+
recordingPreRollMs = 0;
|
|
11338
11709
|
/**
|
|
11339
11710
|
* Effective join-burst bound for this stream's RTSP restream — the cluster
|
|
11340
11711
|
* default, or this camera stream's override. Applies to the RESTREAM
|
|
@@ -11348,20 +11719,9 @@ var StreamBroker = class StreamBroker {
|
|
|
11348
11719
|
* long-GOP high-bitrate stream) drops the bootstrap rather than balloon
|
|
11349
11720
|
* memory — late joiners fall back to waiting for the next keyframe. */
|
|
11350
11721
|
static RTP_RING_MAX_BYTES = 24 * 1024 * 1024;
|
|
11351
|
-
/**
|
|
11352
|
-
*
|
|
11353
|
-
|
|
11354
|
-
*
|
|
11355
|
-
* Its own budget rather than a share of {@link RTP_RING_MAX_BYTES} because
|
|
11356
|
-
* the two failures are different: reaching the ring cap calls
|
|
11357
|
-
* `resetRtpPreBuffer`, which drops the join bootstrap of EVERY consumer —
|
|
11358
|
-
* WebRTC viewers included — for as long as the condition holds. A pre-roll
|
|
11359
|
-
* that pushed a 4K stream into that state would be a knob for one camera's
|
|
11360
|
-
* detection quietly degrading everybody else's picture. Over this budget the
|
|
11361
|
-
* OLDEST retained GOP is dropped whole (never a tail cut), so the window
|
|
11362
|
-
* stays decodable and simply carries less history than asked for.
|
|
11363
|
-
*/
|
|
11364
|
-
static PRE_ROLL_MAX_BYTES = 8 * 1024 * 1024;
|
|
11722
|
+
/** Last budget printed by {@link reportPreRollClamp}, so the line fires on a
|
|
11723
|
+
* TRANSITION rather than on every keyframe. */
|
|
11724
|
+
preRollClampLogged = false;
|
|
11365
11725
|
/** Stream stats tracking */
|
|
11366
11726
|
totalBytes = 0;
|
|
11367
11727
|
bytesInWindow = 0;
|
|
@@ -11401,10 +11761,13 @@ var StreamBroker = class StreamBroker {
|
|
|
11401
11761
|
if (logger) this.rtspRestreamer.setLogger(logger.child("rtsp"));
|
|
11402
11762
|
this.rtspRestreamer.setRtpPreBufferSupplier(() => this.getRtpPreBuffer());
|
|
11403
11763
|
this.rtspRestreamer.setPrimeBoundSupplier(() => this.joinBurstBound);
|
|
11404
|
-
this.rtspRestreamer.setPreRollSupplier(() =>
|
|
11405
|
-
|
|
11406
|
-
|
|
11407
|
-
|
|
11764
|
+
this.rtspRestreamer.setPreRollSupplier((intent) => {
|
|
11765
|
+
const windowMs = intent === "recording" ? this.recordingPreRollMs : this.detectionPreRollMs;
|
|
11766
|
+
return {
|
|
11767
|
+
packets: () => this.getRtpPreRollBuffer(windowMs),
|
|
11768
|
+
preRollMs: windowMs
|
|
11769
|
+
};
|
|
11770
|
+
});
|
|
11408
11771
|
this.preBuffer = new EncodedRingBuffer(StreamBroker.DEFAULT_PRE_BUFFER_SEC);
|
|
11409
11772
|
this.decoderSeedBuffer = new EncodedRingBuffer(StreamBroker.DECODER_SEED_BUFFER_SEC);
|
|
11410
11773
|
this.audioChunkPlane = new AudioChunkPlane(logger?.child("audio-chunk-plane"));
|
|
@@ -11552,6 +11915,9 @@ var StreamBroker = class StreamBroker {
|
|
|
11552
11915
|
if (this.manualStop || this.stopping || !this.source) return;
|
|
11553
11916
|
if (!this.hasDemand()) return;
|
|
11554
11917
|
if (!this.reconnectTimer) return;
|
|
11918
|
+
const nowMs = Date.now();
|
|
11919
|
+
if (nowMs - this.lastKickAt < INITIAL_RECONNECT_DELAY_MS) return;
|
|
11920
|
+
this.lastKickAt = nowMs;
|
|
11555
11921
|
clearTimeout(this.reconnectTimer);
|
|
11556
11922
|
this.reconnectTimer = void 0;
|
|
11557
11923
|
this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
|
|
@@ -12416,16 +12782,61 @@ var StreamBroker = class StreamBroker {
|
|
|
12416
12782
|
* freshly-armed decoder, and a 6 s retention cannot serve a 6 s pre-roll plus
|
|
12417
12783
|
* the GOP that carries it.
|
|
12418
12784
|
*/
|
|
12419
|
-
|
|
12785
|
+
setDetectionPreRollMs(ms) {
|
|
12420
12786
|
const next = Number.isFinite(ms) && ms > 0 ? ms : 0;
|
|
12421
|
-
if (next === this.
|
|
12422
|
-
this.
|
|
12787
|
+
if (next === this.detectionPreRollMs) return;
|
|
12788
|
+
this.detectionPreRollMs = next;
|
|
12423
12789
|
const seedSec = Math.max(StreamBroker.DECODER_SEED_BUFFER_SEC, Math.ceil(next / 1e3) + 2);
|
|
12424
12790
|
this.decoderSeedBuffer.setDuration(seedSec);
|
|
12425
12791
|
}
|
|
12426
|
-
/**
|
|
12427
|
-
|
|
12428
|
-
|
|
12792
|
+
/**
|
|
12793
|
+
* Configure how much history a RECORDING dial is handed.
|
|
12794
|
+
*
|
|
12795
|
+
* Touches the RETENTION (through {@link getPreRollRetentionMs}) and nothing
|
|
12796
|
+
* else: no decoder seed, no change to what any other consumer is served.
|
|
12797
|
+
*/
|
|
12798
|
+
setRecordingPreRollMs(ms) {
|
|
12799
|
+
const next = Number.isFinite(ms) && ms > 0 ? ms : 0;
|
|
12800
|
+
this.recordingPreRollMs = next;
|
|
12801
|
+
}
|
|
12802
|
+
/** The DETECTION pre-roll in force for this stream (ms). `0` = off. */
|
|
12803
|
+
getDetectionPreRollMs() {
|
|
12804
|
+
return this.detectionPreRollMs;
|
|
12805
|
+
}
|
|
12806
|
+
/** The RECORDING pre-roll in force for this stream (ms). `0` = off. */
|
|
12807
|
+
getRecordingPreRollMs() {
|
|
12808
|
+
return this.recordingPreRollMs;
|
|
12809
|
+
}
|
|
12810
|
+
/**
|
|
12811
|
+
* How far back the ring is kept: the WIDER of the two windows.
|
|
12812
|
+
*
|
|
12813
|
+
* One retention serves both because the bytes are the same bytes — the ring
|
|
12814
|
+
* is cut per intent at read time ({@link getRtpPreRollBuffer}), so a 10 s
|
|
12815
|
+
* recording window does not hand a detection dial 10 s of backlog. Retaining
|
|
12816
|
+
* per intent would be two rings of the same packets.
|
|
12817
|
+
*/
|
|
12818
|
+
getPreRollRetentionMs() {
|
|
12819
|
+
return Math.max(this.detectionPreRollMs, this.recordingPreRollMs);
|
|
12820
|
+
}
|
|
12821
|
+
/**
|
|
12822
|
+
* Byte budget for the EXTRA history pre-roll retains, over and above the
|
|
12823
|
+
* current GOP — derived from the window in force and this stream's OBSERVED
|
|
12824
|
+
* bitrate.
|
|
12825
|
+
*
|
|
12826
|
+
* Its own budget rather than a share of {@link RTP_RING_MAX_BYTES} because
|
|
12827
|
+
* the two failures are different: reaching the ring cap calls
|
|
12828
|
+
* `resetRtpPreBuffer`, which drops the join bootstrap of EVERY consumer —
|
|
12829
|
+
* WebRTC viewers included — for as long as the condition holds. A pre-roll
|
|
12830
|
+
* that pushed a 4K stream into that state would be a knob for one camera's
|
|
12831
|
+
* detection quietly degrading everybody else's picture. Over this budget the
|
|
12832
|
+
* OLDEST retained GOP is dropped whole (never a tail cut), so the window
|
|
12833
|
+
* stays decodable and simply carries less history than asked for.
|
|
12834
|
+
*
|
|
12835
|
+
* It replaced a FLAT 8 MiB constant, which was a promise this feature could
|
|
12836
|
+
* not keep in either direction — see {@link preRollBudgetBytes}.
|
|
12837
|
+
*/
|
|
12838
|
+
getPreRollBudget() {
|
|
12839
|
+
return preRollBudgetBytes(this.getPreRollRetentionMs(), this.bitrateKbps);
|
|
12429
12840
|
}
|
|
12430
12841
|
/**
|
|
12431
12842
|
* The seed replayed into a freshly-armed PUSH-mode decoder session.
|
|
@@ -12439,7 +12850,7 @@ var StreamBroker = class StreamBroker {
|
|
|
12439
12850
|
* both, so a fix to the boundary cannot land on only one plane.
|
|
12440
12851
|
*/
|
|
12441
12852
|
getDecoderSeedPackets() {
|
|
12442
|
-
if (this.
|
|
12853
|
+
if (this.detectionPreRollMs <= 0) return this.decoderSeedBuffer.getPackets();
|
|
12443
12854
|
const all = this.decoderSeedBuffer.getAllPackets();
|
|
12444
12855
|
if (all.length === 0) return [];
|
|
12445
12856
|
const newestPts = all[all.length - 1].pts;
|
|
@@ -12451,7 +12862,7 @@ var StreamBroker = class StreamBroker {
|
|
|
12451
12862
|
ageMs: newestPts - packet.pts
|
|
12452
12863
|
});
|
|
12453
12864
|
}
|
|
12454
|
-
const selection = selectPreRollStart(marks, this.
|
|
12865
|
+
const selection = selectPreRollStart(marks, this.detectionPreRollMs);
|
|
12455
12866
|
return selection.startIndex < 0 ? [] : all.slice(selection.startIndex);
|
|
12456
12867
|
}
|
|
12457
12868
|
/** Retained keyframe AUs as ages in ms of media time behind `newestTs`. */
|
|
@@ -12473,14 +12884,18 @@ var StreamBroker = class StreamBroker {
|
|
|
12473
12884
|
trimRtpRingToPreRollWindow() {
|
|
12474
12885
|
if (this.rtpRingKeyframeMarks.length === 0 || this.rtpRing.length === 0) return;
|
|
12475
12886
|
const newestTs = this.rtpRing[this.rtpRing.length - 1].readUInt32BE(4);
|
|
12476
|
-
|
|
12887
|
+
const marks = this.rtpPreRollMarks(newestTs);
|
|
12888
|
+
const retentionMs = this.getPreRollRetentionMs();
|
|
12889
|
+
let cut = selectPreRollStart(marks, retentionMs).startIndex;
|
|
12477
12890
|
if (cut < 0) return;
|
|
12478
|
-
if (
|
|
12891
|
+
if (retentionMs > 0) {
|
|
12892
|
+
const budget = this.getPreRollBudget();
|
|
12893
|
+
this.reportPreRollClamp(budget, retentionMs);
|
|
12479
12894
|
const historyEnd = this.rtpRingKeyframeMarks[this.rtpRingKeyframeMarks.length - 1].index;
|
|
12480
12895
|
let retained = 0;
|
|
12481
12896
|
for (let i = cut; i < historyEnd; i++) retained += this.rtpRing[i].length;
|
|
12482
12897
|
let markIdx = this.rtpRingKeyframeMarks.findIndex((m) => m.index === cut);
|
|
12483
|
-
while (retained >
|
|
12898
|
+
while (retained > budget.bytes && markIdx >= 0 && markIdx < this.rtpRingKeyframeMarks.length - 1) {
|
|
12484
12899
|
const nextCut = this.rtpRingKeyframeMarks[markIdx + 1].index;
|
|
12485
12900
|
for (let i = cut; i < nextCut; i++) retained -= this.rtpRing[i].length;
|
|
12486
12901
|
cut = nextCut;
|
|
@@ -12517,13 +12932,37 @@ var StreamBroker = class StreamBroker {
|
|
|
12517
12932
|
* when motion fired retained nothing, and the honest outcome there is the
|
|
12518
12933
|
* Stage 0 burst rather than a pretend one.
|
|
12519
12934
|
*/
|
|
12520
|
-
getRtpPreRollBuffer() {
|
|
12935
|
+
getRtpPreRollBuffer(windowMs) {
|
|
12521
12936
|
if (!this.rtpRingHasKeyframe) return [];
|
|
12522
|
-
if (
|
|
12937
|
+
if (windowMs <= 0 || this.rtpRing.length === 0) return this.getRtpPreBuffer();
|
|
12523
12938
|
const newestTs = this.rtpRing[this.rtpRing.length - 1].readUInt32BE(4);
|
|
12524
|
-
const selection = selectPreRollStart(this.rtpPreRollMarks(newestTs),
|
|
12939
|
+
const selection = selectPreRollStart(this.rtpPreRollMarks(newestTs), windowMs);
|
|
12525
12940
|
return selection.startIndex < 0 ? [] : this.rtpRing.slice(selection.startIndex);
|
|
12526
12941
|
}
|
|
12942
|
+
/**
|
|
12943
|
+
* Say it, ONCE per transition, when the absolute ceiling truncates the window
|
|
12944
|
+
* the operator configured.
|
|
12945
|
+
*
|
|
12946
|
+
* A clamp nobody prints is indistinguishable from a camera that was simply
|
|
12947
|
+
* not warm long enough, and the two call for opposite actions (buy the RAM
|
|
12948
|
+
* back vs wait). The flat 8 MiB cap this replaced clamped SILENTLY, which is
|
|
12949
|
+
* how "10 s" could mean 6 s on a 4K stream for as long as it did.
|
|
12950
|
+
*/
|
|
12951
|
+
reportPreRollClamp(budget, retentionMs) {
|
|
12952
|
+
if (budget.clamped === this.preRollClampLogged) return;
|
|
12953
|
+
this.preRollClampLogged = budget.clamped;
|
|
12954
|
+
if (!budget.clamped) return;
|
|
12955
|
+
this.logger?.warn("pre-roll budget clamped — the window will carry less than configured", {
|
|
12956
|
+
tags: { deviceId: this.numericDeviceId },
|
|
12957
|
+
meta: {
|
|
12958
|
+
retentionMs,
|
|
12959
|
+
detectionPreRollMs: this.detectionPreRollMs,
|
|
12960
|
+
recordingPreRollMs: this.recordingPreRollMs,
|
|
12961
|
+
bitrateKbps: this.bitrateKbps,
|
|
12962
|
+
budgetBytes: budget.bytes
|
|
12963
|
+
}
|
|
12964
|
+
});
|
|
12965
|
+
}
|
|
12527
12966
|
getSourceType() {
|
|
12528
12967
|
return this.source?.type ?? null;
|
|
12529
12968
|
}
|
|
@@ -12630,7 +13069,9 @@ var StreamBroker = class StreamBroker {
|
|
|
12630
13069
|
getFirstVideoWatchdogStatus() {
|
|
12631
13070
|
return {
|
|
12632
13071
|
armed: this.firstVideoWatchdog !== void 0,
|
|
12633
|
-
videoRtpSeen: this.videoRtpSeen
|
|
13072
|
+
videoRtpSeen: this.videoRtpSeen,
|
|
13073
|
+
timeoutMs: this.firstVideoTimeoutMs(),
|
|
13074
|
+
materializeRequestedAt: this.materializeRequestedAt
|
|
12634
13075
|
};
|
|
12635
13076
|
}
|
|
12636
13077
|
/**
|
|
@@ -13093,6 +13534,7 @@ var StreamBroker = class StreamBroker {
|
|
|
13093
13534
|
this.nextDialAllowedAt = 0;
|
|
13094
13535
|
this.stopErrorPlaceholder();
|
|
13095
13536
|
this.cancelFirstVideoWatchdog();
|
|
13537
|
+
this.materializeRequestedAt = 0;
|
|
13096
13538
|
this.kickRtpActivityWatchdog();
|
|
13097
13539
|
this.videoRtpSeen++;
|
|
13098
13540
|
if (this.videoRtpSeen === 1 || this.videoRtpSeen === 50) this.logger?.info("video: rtp packet received", { meta: {
|
|
@@ -13419,10 +13861,12 @@ var StreamBroker = class StreamBroker {
|
|
|
13419
13861
|
armFirstVideoWatchdog() {
|
|
13420
13862
|
if (this.firstVideoWatchdog) return;
|
|
13421
13863
|
if (this.manualStop || this.stopping) return;
|
|
13864
|
+
const timeoutMs = this.firstVideoTimeoutMs();
|
|
13422
13865
|
this.firstVideoWatchdog = setTimeout(() => {
|
|
13423
13866
|
this.firstVideoWatchdog = void 0;
|
|
13424
13867
|
if (this.manualStop || this.stopping) return;
|
|
13425
13868
|
if (this.videoRtpSeen > 0) {
|
|
13869
|
+
this.materializeRequestedAt = 0;
|
|
13426
13870
|
this.logger?.info("first-video watchdog: video flowing — standing down", {
|
|
13427
13871
|
tags: { deviceId: this.numericDeviceId },
|
|
13428
13872
|
meta: {
|
|
@@ -13435,7 +13879,7 @@ var StreamBroker = class StreamBroker {
|
|
|
13435
13879
|
this.logger?.warn("first-video watchdog: no video RTP after source connect — forcing reconnect", {
|
|
13436
13880
|
tags: { deviceId: this.numericDeviceId },
|
|
13437
13881
|
meta: {
|
|
13438
|
-
timeoutMs
|
|
13882
|
+
timeoutMs,
|
|
13439
13883
|
...this.sourceMetaForLog()
|
|
13440
13884
|
}
|
|
13441
13885
|
});
|
|
@@ -13446,7 +13890,19 @@ var StreamBroker = class StreamBroker {
|
|
|
13446
13890
|
this.lastRefreshRequestAt = 0;
|
|
13447
13891
|
if (this.source?.type === "rfc4571") this.notifySourceRefresh();
|
|
13448
13892
|
this.scheduleReconnect();
|
|
13449
|
-
},
|
|
13893
|
+
}, timeoutMs);
|
|
13894
|
+
}
|
|
13895
|
+
/**
|
|
13896
|
+
* The first-video deadline for the dial being armed.
|
|
13897
|
+
*
|
|
13898
|
+
* Extended for a dial that FOLLOWED a lazy materialize we asked for — see
|
|
13899
|
+
* {@link LAZY_MATERIALIZE_FIRST_VIDEO_TIMEOUT_MS}. It reads the SAME fact the
|
|
13900
|
+
* dial loop uses (`materializeRequestedAt`), so a cold open cannot be "in
|
|
13901
|
+
* progress" for one and "not happening" for the other.
|
|
13902
|
+
*/
|
|
13903
|
+
firstVideoTimeoutMs() {
|
|
13904
|
+
if (this.materializeRequestedAt === 0) return StreamBroker.FIRST_VIDEO_TIMEOUT_MS;
|
|
13905
|
+
return Date.now() - this.materializeRequestedAt < StreamBroker.MATERIALIZE_RECENT_MS ? StreamBroker.LAZY_MATERIALIZE_FIRST_VIDEO_TIMEOUT_MS : StreamBroker.FIRST_VIDEO_TIMEOUT_MS;
|
|
13450
13906
|
}
|
|
13451
13907
|
/** Cancel the first-video watchdog. Idempotent. */
|
|
13452
13908
|
cancelFirstVideoWatchdog() {
|
|
@@ -13651,12 +14107,29 @@ var StreamBroker = class StreamBroker {
|
|
|
13651
14107
|
startRfc4571Reader(source) {
|
|
13652
14108
|
this.logger?.info("starting RFC 4571 reader");
|
|
13653
14109
|
if (typeof source.url === "string" && source.url.startsWith("lazy:rfc4571:")) {
|
|
13654
|
-
|
|
14110
|
+
const nowMs = Date.now();
|
|
14111
|
+
if (this.materializeRequestedAt === 0) this.materializeRequestedAt = nowMs;
|
|
14112
|
+
const waitedMs = nowMs - this.materializeRequestedAt;
|
|
14113
|
+
const withinDeadline = waitedMs < StreamBroker.MATERIALIZE_WAIT_MS;
|
|
14114
|
+
if (withinDeadline) this.reconnectDelayMs = StreamBroker.MATERIALIZE_POLL_MS;
|
|
14115
|
+
this.logger?.info("rfc4571: lazy URL — requesting publisher to materialize upstream", {
|
|
14116
|
+
tags: { deviceId: this.numericDeviceId },
|
|
14117
|
+
meta: {
|
|
14118
|
+
url: source.url,
|
|
14119
|
+
waitedMs,
|
|
14120
|
+
schedule: withinDeadline ? "materialize-poll" : "backoff",
|
|
14121
|
+
nextDelayMs: this.reconnectDelayMs
|
|
14122
|
+
}
|
|
14123
|
+
});
|
|
13655
14124
|
this._status = "error";
|
|
13656
14125
|
this.notifySourceRefresh();
|
|
13657
14126
|
this.scheduleReconnect();
|
|
13658
14127
|
return;
|
|
13659
14128
|
}
|
|
14129
|
+
if (this.materializeRequestedAt > 0) this.logger?.info("rfc4571: publisher materialized — dialling the real upstream", {
|
|
14130
|
+
tags: { deviceId: this.numericDeviceId },
|
|
14131
|
+
meta: { materializeMs: Date.now() - this.materializeRequestedAt }
|
|
14132
|
+
});
|
|
13660
14133
|
const sdp = typeof source.metadata?.["sdp"] === "string" ? source.metadata["sdp"] : null;
|
|
13661
14134
|
if (!sdp) {
|
|
13662
14135
|
this.logger?.warn("rfc4571: source missing metadata.sdp — requesting publisher refresh");
|
|
@@ -14658,6 +15131,13 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
14658
15131
|
* bytes on the ingest node, so it is turned on per camera that needs it.
|
|
14659
15132
|
*/
|
|
14660
15133
|
defaultPreRollMs = 0;
|
|
15134
|
+
/**
|
|
15135
|
+
* Cluster-wide default RECORDING pre-roll (ms). `undefined` until the addon
|
|
15136
|
+
* pushes its config — which is a different fact from `0` and must stay so:
|
|
15137
|
+
* `0` is an operator turning the feature OFF, `undefined` is a boot that has
|
|
15138
|
+
* not read the setting yet and takes `DEFAULT_RECORDING_PRE_ROLL_MS`.
|
|
15139
|
+
*/
|
|
15140
|
+
defaultRecordingPreRollMs = void 0;
|
|
14661
15141
|
webrtcServer = null;
|
|
14662
15142
|
/**
|
|
14663
15143
|
* brokerId → the remote-encoded adapter backing a non-owned camera's WebRTC
|
|
@@ -15302,12 +15782,31 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
15302
15782
|
* decision); an explicit per-stream override still reaches it.
|
|
15303
15783
|
*/
|
|
15304
15784
|
applyPreRoll(broker, deviceId, camStreamId) {
|
|
15305
|
-
|
|
15785
|
+
const terminalRelay = this.cameraStreams.get(deviceId)?.get(camStreamId)?.kind === "pull-http";
|
|
15786
|
+
broker.setDetectionPreRollMs(resolvePreRollMs({
|
|
15306
15787
|
camStreamId,
|
|
15307
15788
|
clusterMs: this.defaultPreRollMs,
|
|
15308
15789
|
overrides: this.deviceOverrides.get(deviceId)?.preRollMs,
|
|
15309
|
-
terminalRelay
|
|
15790
|
+
terminalRelay
|
|
15310
15791
|
}));
|
|
15792
|
+
broker.setRecordingPreRollMs(resolveRecordingPreRollMs({
|
|
15793
|
+
camStreamId,
|
|
15794
|
+
clusterMs: this.defaultRecordingPreRollMs,
|
|
15795
|
+
terminalRelay,
|
|
15796
|
+
batteryOperated: this.deviceHasFeature(deviceId, DeviceFeature.BatteryOperated)
|
|
15797
|
+
}));
|
|
15798
|
+
}
|
|
15799
|
+
/**
|
|
15800
|
+
* Set the cluster-wide default RECORDING pre-roll and push it into every live
|
|
15801
|
+
* broker — same reasoning as detection's: a broker outlives its setting.
|
|
15802
|
+
*/
|
|
15803
|
+
setDefaultRecordingPreRollMs(ms) {
|
|
15804
|
+
this.defaultRecordingPreRollMs = ms;
|
|
15805
|
+
for (const [brokerId, broker] of this.brokers) {
|
|
15806
|
+
const parsed = parseBrokerId(brokerId);
|
|
15807
|
+
if (!parsed) continue;
|
|
15808
|
+
this.applyPreRoll(broker, parsed.deviceId, parsed.camStreamId);
|
|
15809
|
+
}
|
|
15311
15810
|
}
|
|
15312
15811
|
setEventBus(bus) {
|
|
15313
15812
|
this.eventBus = bus;
|
|
@@ -15896,7 +16395,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
15896
16395
|
return { success: true };
|
|
15897
16396
|
}
|
|
15898
16397
|
const existing = this.upsertSourceCameraStream(deviceId, input);
|
|
15899
|
-
|
|
16398
|
+
existing !== void 0 && input.url !== void 0 && (existing.url, input.url);
|
|
15900
16399
|
const wasAlreadyAssigned = (PUSH_KINDS.has(kind) ? findProfileForStream(this.assignments.get(deviceId)?.map ?? {}, camStreamId) : null) !== null && existing !== void 0;
|
|
15901
16400
|
await this.reconcileAutoAssignment(deviceId, "publish", existing === void 0);
|
|
15902
16401
|
if (wasAlreadyAssigned) {
|
|
@@ -15904,7 +16403,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
15904
16403
|
if (finalProfile !== null) this.emitCamStreamDemand(deviceId, camStreamId, finalProfile);
|
|
15905
16404
|
}
|
|
15906
16405
|
await this.ensureBroker(deviceId, camStreamId);
|
|
15907
|
-
|
|
16406
|
+
this.brokers.get(brokerIdFor(deviceId, camStreamId))?.kickReconnect();
|
|
15908
16407
|
const justRegisteredBrokerId = brokerIdFor(deviceId, camStreamId);
|
|
15909
16408
|
const pending = this.pendingDerivedStreams.get(justRegisteredBrokerId);
|
|
15910
16409
|
if (pending) {
|
|
@@ -17765,7 +18264,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
17765
18264
|
else delete pr[camStreamId];
|
|
17766
18265
|
nextOverride.preRollMs = pr;
|
|
17767
18266
|
const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
|
|
17768
|
-
if (broker) broker.
|
|
18267
|
+
if (broker) broker.setDetectionPreRollMs(resolvePreRollMs({
|
|
17769
18268
|
camStreamId,
|
|
17770
18269
|
clusterMs: this.defaultPreRollMs,
|
|
17771
18270
|
overrides: pr,
|
|
@@ -18418,7 +18917,11 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
18418
18917
|
const cam = this.cameraStreams.get(deviceId)?.get(camStreamId);
|
|
18419
18918
|
if (!cam) return;
|
|
18420
18919
|
const brokerId = brokerIdFor(deviceId, camStreamId);
|
|
18421
|
-
|
|
18920
|
+
const live = this.brokers.get(brokerId);
|
|
18921
|
+
if (live) {
|
|
18922
|
+
this.applyPreRoll(live, deviceId, camStreamId);
|
|
18923
|
+
return;
|
|
18924
|
+
}
|
|
18422
18925
|
const existingEnsure = this.brokerEnsures.get(brokerId);
|
|
18423
18926
|
if (existingEnsure) {
|
|
18424
18927
|
await existingEnsure;
|
|
@@ -25450,15 +25953,32 @@ var TimelineSession = class {
|
|
|
25450
25953
|
/**
|
|
25451
25954
|
* The profile this session will actually play, given the one asked for.
|
|
25452
25955
|
*
|
|
25453
|
-
*
|
|
25454
|
-
*
|
|
25455
|
-
*
|
|
25456
|
-
*
|
|
25457
|
-
*
|
|
25458
|
-
*
|
|
25459
|
-
*
|
|
25460
|
-
*
|
|
25461
|
-
*
|
|
25956
|
+
* **The requested tier wins whenever it has footage AT ALL** — a segment
|
|
25957
|
+
* covering `epochMs`, or a gap carrying a `nearestEdgeMs`, which means
|
|
25958
|
+
* "footage exists on this tier, just not here" and is exactly what the
|
|
25959
|
+
* feeder's snap-to-nearest needs. Only a tier that records NOTHING is
|
|
25960
|
+
* abandoned, and then the answer is the first tier that demonstrably does.
|
|
25961
|
+
* Falling back to `requested` when nothing has footage anywhere is the last
|
|
25962
|
+
* resort: the session default is `mid`, and no camera on this cluster records
|
|
25963
|
+
* `mid`, so returning it for a tap into a gap left the feeder with nothing to
|
|
25964
|
+
* snap to and turned a recoverable crack into `seek-no-footage` — the
|
|
25965
|
+
* operator sees "no recording" on a camera that recorded seconds either side.
|
|
25966
|
+
*
|
|
25967
|
+
* The requested tier is checked to EXHAUSTION before any other is consulted,
|
|
25968
|
+
* and that ordering is load-bearing twice over:
|
|
25969
|
+
*
|
|
25970
|
+
* 1. The scrub is pinned to `low` by the client, and every D163 pre-cache
|
|
25971
|
+
* behaviour is gated on `deps.profile === 'low'` by exact string equality
|
|
25972
|
+
* — the 90 s/30 s entry warm, the whole-segment read, and the 24 MB
|
|
25973
|
+
* fragment budget (2 MB for anything else). `profileSearchOrder` puts
|
|
25974
|
+
* `high` BEFORE `low`, so a momentary `low` gap used to hand the drag a
|
|
25975
|
+
* `high` feeder and silently turn all three off. The client cannot repair
|
|
25976
|
+
* that: it re-sends `setProfile` only when its own `lastSentProfile`
|
|
25977
|
+
* mirror disagrees, and the mirror never learns the server moved.
|
|
25978
|
+
* 2. `seek` and `scrubCommit` route through `playAt`, so every finger-release
|
|
25979
|
+
* pays this loop. locate is a cap RPC (1-8 s on cold footage); walking the
|
|
25980
|
+
* ladder to re-confirm a pin the caller already holds is latency the
|
|
25981
|
+
* operator feels as a slow commit.
|
|
25462
25982
|
*/
|
|
25463
25983
|
async resolveProfile(requested, epochMs) {
|
|
25464
25984
|
const locate = this.deps.locate;
|
|
@@ -25476,7 +25996,10 @@ var TimelineSession = class {
|
|
|
25476
25996
|
continue;
|
|
25477
25997
|
}
|
|
25478
25998
|
if (loc.kind === "segment") return profile;
|
|
25479
|
-
if (withFootage === null && loc.nearestEdgeMs !== null)
|
|
25999
|
+
if (withFootage === null && loc.nearestEdgeMs !== null) {
|
|
26000
|
+
if (profile === requested) return profile;
|
|
26001
|
+
withFootage = profile;
|
|
26002
|
+
}
|
|
25480
26003
|
}
|
|
25481
26004
|
return withFootage ?? requested;
|
|
25482
26005
|
}
|
|
@@ -35273,6 +35796,7 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
35273
35796
|
catalogReconcileIntervalSec: 30,
|
|
35274
35797
|
joinBurstMaxSpanMs: DEFAULT_PRIME_BURST_BOUND.maxSpanMs,
|
|
35275
35798
|
preRollMs: 0,
|
|
35799
|
+
recordingPreRollMs: DEFAULT_RECORDING_PRE_ROLL_MS,
|
|
35276
35800
|
binaryPath: "",
|
|
35277
35801
|
hwaccel: "copy"
|
|
35278
35802
|
});
|
|
@@ -35332,6 +35856,7 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
35332
35856
|
this.brokerManager.setDefaultPreBufferSec(this.config.defaultPreBufferSec);
|
|
35333
35857
|
this.brokerManager.setDefaultJoinBurstMaxSpanMs(this.config.joinBurstMaxSpanMs);
|
|
35334
35858
|
this.brokerManager.setDefaultPreRollMs(this.config.preRollMs);
|
|
35859
|
+
this.brokerManager.setDefaultRecordingPreRollMs(this.config.recordingPreRollMs);
|
|
35335
35860
|
this.brokerManager.setEventBus(this.ctx.eventBus);
|
|
35336
35861
|
const localNode = this.ctx.kernel.localNodeId;
|
|
35337
35862
|
const localNodeId = localNode ? localNode.includes("/") ? localNode.split("/")[0] : localNode : void 0;
|
|
@@ -35914,6 +36439,17 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
35914
36439
|
default: 0,
|
|
35915
36440
|
unit: "ms"
|
|
35916
36441
|
},
|
|
36442
|
+
{
|
|
36443
|
+
type: "number",
|
|
36444
|
+
key: "recordingPreRollMs",
|
|
36445
|
+
label: "Recording pre-roll",
|
|
36446
|
+
description: "Cluster-wide default for how much video from BEFORE the trigger an event recording begins with. The recorder’s ffmpeg is spawned AT the trigger, so without this its first frame is at best the live edge — measured on one camera, the footage started after the motion in 60 % of episodes. It spends retained video in memory on whichever node ingests the camera and can only reach as far back as that stream has been warm; it never dials a camera on its own. Battery cameras and terminal (MJPEG relay) cameras take 0. Set 0 to turn it off.",
|
|
36447
|
+
min: 0,
|
|
36448
|
+
max: RECORDING_PRE_ROLL_MAX_MS,
|
|
36449
|
+
step: 500,
|
|
36450
|
+
default: DEFAULT_RECORDING_PRE_ROLL_MS,
|
|
36451
|
+
unit: "ms"
|
|
36452
|
+
},
|
|
35917
36453
|
{
|
|
35918
36454
|
type: "number",
|
|
35919
36455
|
key: "catalogReconcileIntervalSec",
|