@camstack/addon-pipeline 1.2.197 → 1.2.198

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-CM-8U0Bb.js → default-detection-model-B5v8qEI0.js} +1 -1
  4. package/dist/{default-detection-model-CplzzTmN.mjs → default-detection-model-CASpAESz.mjs} +1 -1
  5. package/dist/detection-pipeline/index.js +5 -5
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-CBa1j3On.js → dist-BxP0SvPz.js} +74 -23
  8. package/dist/{dist-Y4BsF_EC.mjs → dist-oFIHnD88.mjs} +69 -24
  9. package/dist/{lazy-sharp-CS5WE9mI.js → lazy-sharp-VKOG3WW4.js} +1 -1
  10. package/dist/motion-wasm/index.js +2 -2
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-B5ouEQYN.mjs → node-B4zETsJ9.mjs} +1 -1
  13. package/dist/{node-C-B8VuGi.js → node-BIKrNEtS.js} +1 -1
  14. package/dist/pipeline-runner/index.js +5 -5
  15. package/dist/pipeline-runner/index.mjs +4 -4
  16. package/dist/{retire-root-keys-Csb8z2f3.js → prebuffer-DmBYRQWg.mjs} +64 -12
  17. package/dist/{retire-root-keys-DZvlso2l.mjs → prebuffer-DqdeLSsk.js} +99 -1
  18. package/dist/{process-memory-CmeKr_MZ.js → process-memory-CMHOmj75.js} +1 -1
  19. package/dist/{process-memory-DeO2OZ3-.mjs → process-memory-D9xYwm6L.mjs} +1 -1
  20. package/dist/recorder/index.js +26 -18
  21. package/dist/recorder/index.mjs +22 -14
  22. package/dist/{segment-demux-js-DiIsywGz.js → segment-demux-js-DLwL1vmq.js} +1 -1
  23. package/dist/{segment-demux-js-DXPgq2cf.mjs → segment-demux-js-DZysrgdC.mjs} +1 -1
  24. package/dist/session-decode/decode-worker-child.js +2 -2
  25. package/dist/session-decode/decode-worker-child.mjs +1 -1
  26. package/dist/stream-broker/_stub.js +28 -31
  27. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Q-6HzWJ0.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C2eTdxvd.mjs} +3 -3
  28. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Cv5McbTI.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BcjLrxhF.mjs} +1 -1
  29. package/dist/stream-broker/demux-worker-child.js +1 -1
  30. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  31. package/dist/stream-broker/{hostInit-C-2Eo_nr.mjs → hostInit-C8tu7NS4.mjs} +3 -3
  32. package/dist/stream-broker/index.js +452 -541
  33. package/dist/stream-broker/index.mjs +448 -537
  34. package/dist/stream-broker/remoteEntry.js +1 -1
  35. package/dist/{worker-protocol-CXsdriRX.js → worker-protocol-D2mMjUXv.js} +1 -1
  36. package/dist/{worker-protocol-_7zrToTo.mjs → worker-protocol-DFlZm_wr.mjs} +1 -1
  37. package/package.json +1 -1
@@ -2,12 +2,12 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-CBa1j3On.js");
6
- const require_node = require("../node-C-B8VuGi.js");
5
+ const require_dist = require("../dist-BxP0SvPz.js");
6
+ const require_node = require("../node-BIKrNEtS.js");
7
7
  const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
8
8
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
9
- const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
10
- const require_segment_demux_js = require("../segment-demux-js-DiIsywGz.js");
9
+ const require_prebuffer = require("../prebuffer-DqdeLSsk.js");
10
+ const require_segment_demux_js = require("../segment-demux-js-DLwL1vmq.js");
11
11
  let node_os = require("node:os");
12
12
  node_os = require_dist.__toESM(node_os);
13
13
  let node_crypto = require("node:crypto");
@@ -370,7 +370,7 @@ var BROKER_PROBE_SNAPSHOTS_SPEC = {
370
370
  loadLimit: BROKER_ROWS_LIMIT
371
371
  };
372
372
  function probeSnapshotsStore(store, logger) {
373
- return new require_retire_root_keys.RowMapStore({
373
+ return new require_prebuffer.RowMapStore({
374
374
  spec: BROKER_PROBE_SNAPSHOTS_SPEC,
375
375
  store,
376
376
  logger
@@ -412,7 +412,7 @@ var BROKER_RTSP_TOKENS_SPEC = {
412
412
  loadLimit: BROKER_ROWS_LIMIT
413
413
  };
414
414
  function rtspTokensStore(store, logger) {
415
- return new require_retire_root_keys.RowMapStore({
415
+ return new require_prebuffer.RowMapStore({
416
416
  spec: BROKER_RTSP_TOKENS_SPEC,
417
417
  store,
418
418
  logger
@@ -453,7 +453,7 @@ var BROKER_PROFILE_MAP_SPEC = {
453
453
  loadLimit: BROKER_ROWS_LIMIT
454
454
  };
455
455
  function profileMapStore(store, logger) {
456
- return new require_retire_root_keys.RowMapStore({
456
+ return new require_prebuffer.RowMapStore({
457
457
  spec: BROKER_PROFILE_MAP_SPEC,
458
458
  store,
459
459
  logger
@@ -1785,247 +1785,66 @@ function rankInitialAssignment(streams) {
1785
1785
  ...byRank
1786
1786
  };
1787
1787
  }
1788
+ //#endregion
1789
+ //#region src/stream-broker/prebuffer-migration.ts
1788
1790
  /**
1789
- * Ceiling for a configured pre-roll.
1791
+ * MIGRATION of the retired per-stream pre-roll override onto the one prebuffer.
1790
1792
  *
1791
- * Three costs grow with this number and none of them is the operator's to
1792
- * discover: retained RTP bytes on the ingest node, the media backlog a decoder
1793
- * must drain before it reaches the live edge, and the span a detection burst is
1794
- * allowed to carry past the D57 bound. 6 s covers a 4-5 s IDR interval plus a
1795
- * full GOP of head-room and matches the decoder-seed ring's own retention, so
1796
- * both planes can honour the same maximum.
1797
- */
1798
- var PRE_ROLL_MAX_MS = 6e3;
1799
- var NOTHING = {
1800
- startIndex: -1,
1801
- ageMs: 0,
1802
- gopsBack: 0
1803
- };
1804
- /**
1805
- * A configured pre-roll in ms, or `0` when the input carries no instruction.
1793
+ * `DeviceOverride.preRollMs` was the per-camera-stream DETECTION pre-roll: how
1794
+ * much history a decode dial could be handed. D381 retires it — there is one
1795
+ * retention now and it is the prebuffer's — but a key that is merely removed
1796
+ * from a non-strict `z.object` is **stripped in silence** on the next save,
1797
+ * which is the failure D380 came within one commit of shipping.
1806
1798
  *
1807
- * `maxMs` is the ceiling of the WINDOW being resolved: detection's
1808
- * ({@link PRE_ROLL_MAX_MS}) bounds a decoder's drain, recording's
1809
- * ({@link RECORDING_PRE_ROLL_MAX_MS}) bounds the recorder's keep gate. One
1810
- * clamp for both so a new window cannot arrive with its own rounding rules.
1799
+ * So the key is retired EXPLICITLY: read once, converted into the
1800
+ * configuration that now carries its meaning, and cleared. An operator who had
1801
+ * typed "3 s of history on 617's substream" keeps 3 s of history on 617's
1802
+ * substream; they do not silently get the cluster default, and they do not
1803
+ * silently get nothing.
1811
1804
  */
1812
- function clampPreRoll(value, maxMs) {
1813
- if (value === void 0) return 0;
1814
- if (!Number.isFinite(value) || value <= 0) return 0;
1815
- return Math.min(maxMs, Math.max(250, value));
1816
- }
1817
1805
  /**
1818
- * {@link clampPreRoll} at the WIDEST ceiling any window in this system may
1819
- * carry.
1806
+ * Fold a retired `preRollMs` map into the prebuffer overrides.
1820
1807
  *
1821
- * Used by the three functions that operate on a window ALREADY resolved by
1822
- * `resolvePreRollMs` / `resolveRecordingPreRollMs` — the selection, the packet
1823
- * ceiling and the span bound. Clamping those at the DETECTION ceiling was
1824
- * correct while there was one window and became a silent 6 s truncation of
1825
- * every recording burst the moment there were two: the selector would refuse
1826
- * the keyframe the recorder asked for, and the span bound would withhold the
1827
- * burst for being as wide as the operator configured — D192's failure, one
1828
- * layer down.
1829
- */
1830
- function usablePreRoll(value) {
1831
- return clampPreRoll(value, RECORDING_PRE_ROLL_MAX_MS);
1832
- }
1833
- /**
1834
- * Choose the keyframe access unit a pre-roll burst starts at.
1835
- *
1836
- * The OLDEST mark that is still inside the window wins; when none is (the
1837
- * current GOP is itself older than `preRollMs`, i.e. a long-GOP camera with a
1838
- * short pre-roll) the newest mark is returned, because the alternative to the
1839
- * current GOP is nothing at all — the blind start Stage 0 exists to prevent.
1808
+ * An EXPLICIT prebuffer override wins and the retired value is dropped: the
1809
+ * operator answered the same question later and more directly, and a migration
1810
+ * that overwrote it would be this subsystem's original sin one more time — two
1811
+ * writers for one number.
1840
1812
  *
1841
- * The boundary is INCLUSIVE: a keyframe exactly `preRollMs` old is inside.
1842
- * Everything delivered after a keyframe is younger than it, so starting at a
1843
- * mark inside the window is what makes "no frame older than `preRollMs` is
1844
- * delivered" a property of the slice rather than a per-packet filter.
1845
- *
1846
- * Scans instead of assuming order: a caller that appends marks and rebases
1847
- * indices after a trim is one refactor away from handing this an unsorted
1848
- * array, and the failure would be a silently wider window.
1813
+ * Rounds UP to whole seconds. The prebuffer is configured in seconds and the
1814
+ * retired knob in milliseconds; rounding down would hand back a window
1815
+ * narrower than the one that was running, which is the direction that loses
1816
+ * media.
1849
1817
  */
1850
- function selectPreRollStart(marks, preRollMs) {
1851
- if (marks.length === 0) return NOTHING;
1852
- const window = usablePreRoll(preRollMs);
1853
- let newest = marks[0];
1854
- let chosen = null;
1855
- for (const mark of marks) {
1856
- if (mark.ageMs < newest.ageMs) newest = mark;
1857
- if (window > 0 && mark.ageMs <= window && (chosen === null || mark.ageMs > chosen.ageMs)) chosen = mark;
1858
- }
1859
- const pick = chosen ?? newest;
1860
- let gopsBack = 0;
1861
- for (const mark of marks) if (mark.ageMs < pick.ageMs) gopsBack++;
1862
- return {
1863
- startIndex: pick.index,
1864
- ageMs: pick.ageMs,
1865
- gopsBack
1818
+ function migrateRetiredPreRollOverride(input) {
1819
+ const retired = input.preRollMs;
1820
+ if (retired === void 0 || Object.keys(retired).length === 0) return {
1821
+ override: input,
1822
+ migrated: [],
1823
+ superseded: []
1866
1824
  };
1867
- }
1868
- /**
1869
- * Ceiling for the RECORDING window.
1870
- *
1871
- * Wider than {@link PRE_ROLL_MAX_MS} because the two windows are bounded by
1872
- * different things. Detection's ceiling is the backlog a decoder must drain
1873
- * before it reaches the live edge — media the analyser pays for frame by frame.
1874
- * A recorder drains nothing: ffmpeg copies the burst to disk. What bounds THIS
1875
- * number is the recorder's retroactive keep window (`preBufferSec`), because
1876
- * footage the broker hands the writer from before that bound is deleted by the
1877
- * keep gate the moment it finalizes — the divergence D191/D192 exist to forbid.
1878
- * `packages/addon-pipeline/src/recorder/addon/__tests__/band-decision.spec.ts`
1879
- * fails if this is ever raised past the shipped keep window.
1880
- */
1881
- var RECORDING_PRE_ROLL_MAX_MS = 15e3;
1882
- /**
1883
- * Shipped RECORDING pre-roll on a mains camera. Operator decision, 2026-08-20.
1884
- *
1885
- * Unlike detection's, this one ships ON. The audit of the same day measured the
1886
- * reason: on camera 1438 the footage began AFTER the motion in 60 % of
1887
- * episodes, because the writer is spawned at the trigger and ffmpeg's first
1888
- * frame is at best the live edge. A recording feature whose media starts after
1889
- * the event is not a tuning question.
1890
- */
1891
- var DEFAULT_RECORDING_PRE_ROLL_MS = 1e4;
1892
- /**
1893
- * Budget when the broker has not measured a bitrate for this stream yet — the
1894
- * historical flat cap, kept verbatim as the honest "we do not know".
1895
- *
1896
- * `bitrateKbps` is 0 for the first stats window after every dial and after
1897
- * every reset, so this is the value in force exactly while the ring is filling
1898
- * for the first time.
1899
- */
1900
- var PRE_ROLL_UNKNOWN_BITRATE_BYTES = 8 * 1024 * 1024;
1901
- /**
1902
- * Hard ceiling on the WHOLE source-RTP ring (history + the current GOP).
1903
- *
1904
- * Lives here, next to the pre-roll budget, because the two numbers are one
1905
- * number: a history budget above the ring cap is not a wider window, it is a
1906
- * ring that overflows and calls `resetRtpPreBuffer`, which drops the join
1907
- * bootstrap of EVERY consumer — WebRTC viewers included — for as long as the
1908
- * condition holds. Keeping them in separate files is how they came to disagree.
1909
- * Read by `StreamBroker.captureRtpForPreBuffer`.
1910
- */
1911
- var RTP_RING_MAX_BYTES = 24 * 1024 * 1024;
1912
- /**
1913
- * Absolute per-stream ceiling on retained pre-roll HISTORY.
1914
- *
1915
- * The bitrate-derived budget is what the window COSTS; this is the most any one
1916
- * stream may spend regardless. Past it the operator has configured a window this
1917
- * node cannot afford on that camera, and the broker says so rather than
1918
- * delivering silently less (D192).
1919
- *
1920
- * **DERIVED, not chosen.** It was a free-standing `32 * 1024 * 1024` — 8 MiB
1921
- * ABOVE the ring it has to fit inside. A ≳13 Mbps stream at a 10 s window was
1922
- * therefore authorised to overflow its own ring and cycle `resetRtpPreBuffer`:
1923
- * the pre-roll knob, turned up, would have taken every viewer's join bootstrap
1924
- * with it. Deriving it from {@link RTP_RING_MAX_BYTES} minus the current-GOP
1925
- * reserve makes the inversion unrepresentable rather than merely fixed.
1926
- */
1927
- var PRE_ROLL_ABSOLUTE_MAX_BYTES = RTP_RING_MAX_BYTES - 8 * 1024 * 1024;
1928
- /**
1929
- * Headroom over the OBSERVED mean bitrate.
1930
- *
1931
- * `BrokerStatsSchema.bitrateKbps` is a mean over the stats window; a GOP
1932
- * carrying an IDR plus a scene change is well above it. Budgeting at exactly
1933
- * the mean would drop the oldest GOP on every busy second, so the window would
1934
- * be the one the operator asked for only while nothing moved — which is the
1935
- * opposite of when it is needed.
1936
- */
1937
- var PRE_ROLL_BITRATE_HEADROOM = 1.5;
1938
- /**
1939
- * The byte budget for the history a `windowMs` pre-roll needs on a stream
1940
- * observed at `observedKbps`.
1941
- *
1942
- * Replaces a FLAT 8 MiB cap. That constant was a promise the feature could not
1943
- * keep in either direction: above ~6.5 Mbps it silently truncated the window
1944
- * (a 4K main stream asked for 10 s and got 6), and below it, it authorised a
1945
- * doorbell at 512 kbps to hold 8 MiB — 16x what its own window weighs.
1946
- */
1947
- function preRollBudgetBytes(windowMs, observedKbps) {
1948
- if (!Number.isFinite(windowMs) || windowMs <= 0) return {
1949
- bytes: 0,
1950
- clamped: false
1951
- };
1952
- if (!Number.isFinite(observedKbps) || observedKbps <= 0) return {
1953
- bytes: PRE_ROLL_UNKNOWN_BITRATE_BYTES,
1954
- clamped: false
1955
- };
1956
- const raw = Math.round(windowMs * observedKbps * PRE_ROLL_BITRATE_HEADROOM / 8);
1957
- if (raw > 16777216) return {
1958
- bytes: PRE_ROLL_ABSOLUTE_MAX_BYTES,
1959
- clamped: true
1960
- };
1961
- return {
1962
- bytes: raw,
1963
- clamped: false
1964
- };
1965
- }
1966
- /**
1967
- * The RECORDING window a broker retains and serves to a `/recording` dial.
1968
- *
1969
- * Precedence: stream override -> battery / terminal relay (0) -> cluster
1970
- * default -> {@link DEFAULT_RECORDING_PRE_ROLL_MS}. An override wins over the
1971
- * battery rule on purpose: the rule is a DEFAULT about where retention is
1972
- * wasted, and an operator who typed a number for that stream has answered the
1973
- * question the default was guessing at.
1974
- */
1975
- function resolveRecordingPreRollMs(input) {
1976
- const override = clampPreRoll(input.overrides?.[input.camStreamId], RECORDING_PRE_ROLL_MAX_MS);
1977
- if (override > 0) return override;
1978
- if (input.batteryOperated === true) return 0;
1979
- if (input.terminalRelay === true) return 0;
1980
- if (input.clusterMs === void 0) return DEFAULT_RECORDING_PRE_ROLL_MS;
1981
- return clampPreRoll(input.clusterMs, RECORDING_PRE_ROLL_MAX_MS);
1982
- }
1983
- /**
1984
- * Combine the cluster default and the per-camera-stream override into the one
1985
- * pre-roll a broker applies. Precedence: stream override → cluster default →
1986
- * OFF. Each layer is validated independently, so a bad override falls back to a
1987
- * good cluster value rather than to nothing.
1988
- *
1989
- * `0` means INHERIT, never "off for this stream". It is what a cleared number
1990
- * input posts, and the shipped cluster default is 0, so inherit and off
1991
- * coincide on every unconfigured stream.
1992
- */
1993
- function resolvePreRollMs(input) {
1994
- const override = clampPreRoll(input.overrides?.[input.camStreamId], PRE_ROLL_MAX_MS);
1995
- if (override > 0) return override;
1996
- if (input.terminalRelay === true) return 0;
1997
- return clampPreRoll(input.clusterMs, PRE_ROLL_MAX_MS);
1998
- }
1999
- /**
2000
- * The packet ceiling for a burst carrying `preRollMs` of pre-roll.
2001
- *
2002
- * `PRIME_MAX_PACKETS` is a defensive backstop against RTP timestamps that
2003
- * stopped describing the stream — "far more packets than this much media time
2004
- * can contain" — not a tuning knob. Read as an absolute it would silently kill
2005
- * pre-roll on exactly the cameras that need it (a 4K main stream at 8 Mbps
2006
- * fills 2 000 MTU-sized packets in under 3 s), so it is applied as the RATE it
2007
- * always was: the same packets-per-span, over the span actually requested.
2008
- * Never shrinks the ceiling.
2009
- */
2010
- function preRollPacketCeiling(bound, preRollMs) {
2011
- const window = usablePreRoll(preRollMs);
2012
- if (window <= 0 || bound.maxSpanMs <= 0) return bound.maxPackets;
2013
- const factor = Math.max(1, Math.ceil(window / bound.maxSpanMs));
2014
- return bound.maxPackets * factor;
2015
- }
2016
- /**
2017
- * The span bound a DETECTION session's burst is held to while pre-roll is on.
2018
- *
2019
- * D57's bound is untouched for every other consumer; for this one it becomes
2020
- * the wider of the two, because withholding a burst for being as wide as the
2021
- * operator configured would make the knob read as ON and behave as OFF.
2022
- */
2023
- function preRollSpanBound(bound, preRollMs) {
2024
- const window = usablePreRoll(preRollMs);
2025
- if (window <= 0) return bound;
1825
+ const preBuffer = { ...input.preBuffer };
1826
+ const migrated = [];
1827
+ const superseded = [];
1828
+ for (const [camStreamId, ms] of Object.entries(retired)) {
1829
+ if (!Number.isFinite(ms) || ms <= 0) continue;
1830
+ if (input.preBuffer?.[camStreamId] !== void 0) {
1831
+ superseded.push(camStreamId);
1832
+ continue;
1833
+ }
1834
+ preBuffer[camStreamId] = {
1835
+ enabled: true,
1836
+ seconds: Math.min(15, Math.max(1, Math.ceil(ms / 1e3)))
1837
+ };
1838
+ migrated.push(camStreamId);
1839
+ }
1840
+ const { preRollMs: _retired, ...rest } = input;
2026
1841
  return {
2027
- maxSpanMs: Math.max(bound.maxSpanMs, window),
2028
- maxPackets: preRollPacketCeiling(bound, window)
1842
+ override: Object.keys(preBuffer).length > 0 ? {
1843
+ ...rest,
1844
+ preBuffer
1845
+ } : rest,
1846
+ migrated,
1847
+ superseded
2029
1848
  };
2030
1849
  }
2031
1850
  //#endregion
@@ -2201,6 +2020,200 @@ var PacedRtpReplay = class {
2201
2020
  }
2202
2021
  };
2203
2022
  //#endregion
2023
+ //#region src/stream-broker/pre-roll.ts
2024
+ /**
2025
+ * PRE-ROLL MECHANICS — how a window of retained history is CUT, BUDGETED and
2026
+ * BOUNDED. Not how wide it is: that is the prebuffer's, and the prebuffer's
2027
+ * alone (`prebuffer.ts`, D381).
2028
+ *
2029
+ * WHAT LIVES HERE. Three mechanisms, none of them a setting:
2030
+ *
2031
+ * - {@link selectPreRollStart} — which retained keyframe access unit a burst
2032
+ * starts at. Keyframe-aligned because D57's "the whole GOP or none of it" is
2033
+ * the reason this selects among marks rather than slicing by time.
2034
+ * - {@link preRollBudgetBytes} — what a window of a given width WEIGHS on a
2035
+ * stream of a measured bitrate, and the ring ceiling it has to fit inside.
2036
+ * - {@link preRollSpanBound} / {@link preRollPacketCeiling} — D57's join bound,
2037
+ * widened for the one session that asked for history, with the packet ceiling
2038
+ * scaled by the same factor so the defensive backstop stays a RATE.
2039
+ *
2040
+ * WHAT USED TO LIVE HERE AND DOES NOT. `resolvePreRollMs`,
2041
+ * `resolveRecordingPreRollMs`, `DEFAULT_PRE_ROLL_MS`,
2042
+ * `DEFAULT_RECORDING_PRE_ROLL_MS` and their two ceilings — a whole
2043
+ * CONFIGURATION layer, two windows deep, that decided how much history each
2044
+ * kind of consumer got. It is gone: the prebuffer decides, once, and a consumer
2045
+ * asks for what it holds. The mechanics below are unchanged, because the
2046
+ * failures they encode (an unpaced burst, a flat byte cap, a silently truncated
2047
+ * window) are about cutting history, not about choosing how much to keep.
2048
+ *
2049
+ * WHAT IT STILL CANNOT DO. Spending history is not creating it. A source that
2050
+ * was idle when motion fired has none, the selection falls back to the current
2051
+ * GOP, and the honest outcome is the ordinary join burst.
2052
+ */
2053
+ var NOTHING = {
2054
+ startIndex: -1,
2055
+ ageMs: 0,
2056
+ gopsBack: 0
2057
+ };
2058
+ /**
2059
+ * Clamp a window ALREADY resolved by the prebuffer to something this module can
2060
+ * cut.
2061
+ *
2062
+ * One clamp for every mechanism below — the selection, the packet ceiling and
2063
+ * the span bound — so a caller cannot get a wider window out of one of them
2064
+ * than out of the others. {@link PREBUFFER_MAX_MS} is the ceiling because the
2065
+ * prebuffer is the only thing that sets a window now; it was
2066
+ * `RECORDING_PRE_ROLL_MAX_MS`, and before that the DETECTION ceiling, which
2067
+ * silently truncated every recording burst to 6 s the moment there were two
2068
+ * windows — D192's failure, one layer down.
2069
+ */
2070
+ function usablePreRoll(value) {
2071
+ if (value === void 0) return 0;
2072
+ if (!Number.isFinite(value) || value <= 0) return 0;
2073
+ return Math.min(require_prebuffer.PREBUFFER_MAX_MS, value);
2074
+ }
2075
+ /**
2076
+ * Choose the keyframe access unit a pre-roll burst starts at.
2077
+ *
2078
+ * The OLDEST mark that is still inside the window wins; when none is (the
2079
+ * current GOP is itself older than `preRollMs`, i.e. a long-GOP camera with a
2080
+ * short pre-roll) the newest mark is returned, because the alternative to the
2081
+ * current GOP is nothing at all — the blind start Stage 0 exists to prevent.
2082
+ *
2083
+ * The boundary is INCLUSIVE: a keyframe exactly `preRollMs` old is inside.
2084
+ * Everything delivered after a keyframe is younger than it, so starting at a
2085
+ * mark inside the window is what makes "no frame older than `preRollMs` is
2086
+ * delivered" a property of the slice rather than a per-packet filter.
2087
+ *
2088
+ * Scans instead of assuming order: a caller that appends marks and rebases
2089
+ * indices after a trim is one refactor away from handing this an unsorted
2090
+ * array, and the failure would be a silently wider window.
2091
+ */
2092
+ function selectPreRollStart(marks, preRollMs) {
2093
+ if (marks.length === 0) return NOTHING;
2094
+ const window = usablePreRoll(preRollMs);
2095
+ let newest = marks[0];
2096
+ let chosen = null;
2097
+ for (const mark of marks) {
2098
+ if (mark.ageMs < newest.ageMs) newest = mark;
2099
+ if (window > 0 && mark.ageMs <= window && (chosen === null || mark.ageMs > chosen.ageMs)) chosen = mark;
2100
+ }
2101
+ const pick = chosen ?? newest;
2102
+ let gopsBack = 0;
2103
+ for (const mark of marks) if (mark.ageMs < pick.ageMs) gopsBack++;
2104
+ return {
2105
+ startIndex: pick.index,
2106
+ ageMs: pick.ageMs,
2107
+ gopsBack
2108
+ };
2109
+ }
2110
+ /**
2111
+ * Budget when the broker has not measured a bitrate for this stream yet — the
2112
+ * historical flat cap, kept verbatim as the honest "we do not know".
2113
+ *
2114
+ * `bitrateKbps` is 0 for the first stats window after every dial and after
2115
+ * every reset, so this is the value in force exactly while the ring is filling
2116
+ * for the first time.
2117
+ */
2118
+ var PRE_ROLL_UNKNOWN_BITRATE_BYTES = 8 * 1024 * 1024;
2119
+ /**
2120
+ * Hard ceiling on the WHOLE source-RTP ring (history + the current GOP).
2121
+ *
2122
+ * Lives here, next to the pre-roll budget, because the two numbers are one
2123
+ * number: a history budget above the ring cap is not a wider window, it is a
2124
+ * ring that overflows and calls `resetRtpPreBuffer`, which drops the join
2125
+ * bootstrap of EVERY consumer — WebRTC viewers included — for as long as the
2126
+ * condition holds. Keeping them in separate files is how they came to disagree.
2127
+ * Read by `StreamBroker.captureRtpForPreBuffer`.
2128
+ */
2129
+ var RTP_RING_MAX_BYTES = 24 * 1024 * 1024;
2130
+ /**
2131
+ * Absolute per-stream ceiling on retained pre-roll HISTORY.
2132
+ *
2133
+ * The bitrate-derived budget is what the window COSTS; this is the most any one
2134
+ * stream may spend regardless. Past it the operator has configured a window this
2135
+ * node cannot afford on that camera, and the broker says so rather than
2136
+ * delivering silently less (D192).
2137
+ *
2138
+ * **DERIVED, not chosen.** It was a free-standing `32 * 1024 * 1024` — 8 MiB
2139
+ * ABOVE the ring it has to fit inside. A ≳13 Mbps stream at a 10 s window was
2140
+ * therefore authorised to overflow its own ring and cycle `resetRtpPreBuffer`:
2141
+ * the pre-roll knob, turned up, would have taken every viewer's join bootstrap
2142
+ * with it. Deriving it from {@link RTP_RING_MAX_BYTES} minus the current-GOP
2143
+ * reserve makes the inversion unrepresentable rather than merely fixed.
2144
+ */
2145
+ var PRE_ROLL_ABSOLUTE_MAX_BYTES = RTP_RING_MAX_BYTES - 8 * 1024 * 1024;
2146
+ /**
2147
+ * Headroom over the OBSERVED mean bitrate.
2148
+ *
2149
+ * `BrokerStatsSchema.bitrateKbps` is a mean over the stats window; a GOP
2150
+ * carrying an IDR plus a scene change is well above it. Budgeting at exactly
2151
+ * the mean would drop the oldest GOP on every busy second, so the window would
2152
+ * be the one the operator asked for only while nothing moved — which is the
2153
+ * opposite of when it is needed.
2154
+ */
2155
+ var PRE_ROLL_BITRATE_HEADROOM = 1.5;
2156
+ /**
2157
+ * The byte budget for the history a `windowMs` pre-roll needs on a stream
2158
+ * observed at `observedKbps`.
2159
+ *
2160
+ * Replaces a FLAT 8 MiB cap. That constant was a promise the feature could not
2161
+ * keep in either direction: above ~6.5 Mbps it silently truncated the window
2162
+ * (a 4K main stream asked for 10 s and got 6), and below it, it authorised a
2163
+ * doorbell at 512 kbps to hold 8 MiB — 16x what its own window weighs.
2164
+ */
2165
+ function preRollBudgetBytes(windowMs, observedKbps) {
2166
+ if (!Number.isFinite(windowMs) || windowMs <= 0) return {
2167
+ bytes: 0,
2168
+ clamped: false
2169
+ };
2170
+ if (!Number.isFinite(observedKbps) || observedKbps <= 0) return {
2171
+ bytes: PRE_ROLL_UNKNOWN_BITRATE_BYTES,
2172
+ clamped: false
2173
+ };
2174
+ const raw = Math.round(windowMs * observedKbps * PRE_ROLL_BITRATE_HEADROOM / 8);
2175
+ if (raw > 16777216) return {
2176
+ bytes: PRE_ROLL_ABSOLUTE_MAX_BYTES,
2177
+ clamped: true
2178
+ };
2179
+ return {
2180
+ bytes: raw,
2181
+ clamped: false
2182
+ };
2183
+ }
2184
+ /**
2185
+ * The packet ceiling for a burst carrying `preRollMs` of pre-roll.
2186
+ *
2187
+ * `PRIME_MAX_PACKETS` is a defensive backstop against RTP timestamps that
2188
+ * stopped describing the stream — "far more packets than this much media time
2189
+ * can contain" — not a tuning knob. Read as an absolute it would silently kill
2190
+ * pre-roll on exactly the cameras that need it (a 4K main stream at 8 Mbps
2191
+ * fills 2 000 MTU-sized packets in under 3 s), so it is applied as the RATE it
2192
+ * always was: the same packets-per-span, over the span actually requested.
2193
+ * Never shrinks the ceiling.
2194
+ */
2195
+ function preRollPacketCeiling(bound, preRollMs) {
2196
+ const window = usablePreRoll(preRollMs);
2197
+ if (window <= 0 || bound.maxSpanMs <= 0) return bound.maxPackets;
2198
+ const factor = Math.max(1, Math.ceil(window / bound.maxSpanMs));
2199
+ return bound.maxPackets * factor;
2200
+ }
2201
+ /**
2202
+ * The span bound a DETECTION session's burst is held to while pre-roll is on.
2203
+ *
2204
+ * D57's bound is untouched for every other consumer; for this one it becomes
2205
+ * the wider of the two, because withholding a burst for being as wide as the
2206
+ * operator configured would make the knob read as ON and behave as OFF.
2207
+ */
2208
+ function preRollSpanBound(bound, preRollMs) {
2209
+ const window = usablePreRoll(preRollMs);
2210
+ if (window <= 0) return bound;
2211
+ return {
2212
+ maxSpanMs: Math.max(bound.maxSpanMs, window),
2213
+ maxPackets: preRollPacketCeiling(bound, window)
2214
+ };
2215
+ }
2216
+ //#endregion
2204
2217
  //#region src/stream-broker/rtsp/annexb-deframer.ts
2205
2218
  /**
2206
2219
  * Annex-B NAL unit deframer.
@@ -2978,7 +2991,7 @@ var RtspRestreamer = class {
2978
2991
  if (primePackets.length === 0) return;
2979
2992
  const firstTs = primePackets[0].data.readUInt32BE(4);
2980
2993
  const spanMs = (primePackets[primePackets.length - 1].data.readUInt32BE(4) - firstTs | 0) / 90;
2981
- const preRollPlans = /* @__PURE__ */ new Map();
2994
+ const preRollState = { plan: null };
2982
2995
  const bound = this.currentPrimeBound();
2983
2996
  /**
2984
2997
  * Resolve (and memoize) the plan for one intent.
@@ -2990,10 +3003,9 @@ var RtspRestreamer = class {
2990
3003
  * current GOP or a supplier bug can, and D57's remedy (join clean at the
2991
3004
  * next live keyframe) is the right one for a burst nobody asked for.
2992
3005
  */
2993
- const planFor = (intent) => {
2994
- const cached = preRollPlans.get(intent);
2995
- if (cached) return cached;
2996
- const burst = this.preRollSupplier?.(intent);
3006
+ const planFor = () => {
3007
+ if (preRollState.plan) return preRollState.plan;
3008
+ const burst = this.preRollSupplier?.();
2997
3009
  const window = fromRing && burst && burst.preRollMs > 0 ? burst.packets() : [];
2998
3010
  const packets = window.length > primePackets.length ? window.map((data) => ({
2999
3011
  data,
@@ -3008,7 +3020,7 @@ var RtspRestreamer = class {
3008
3020
  spanMs: windowSpanMs,
3009
3021
  overBound: packets.length > 0 && (windowSpanMs > windowBound.maxSpanMs || packets.length > windowBound.maxPackets)
3010
3022
  };
3011
- preRollPlans.set(intent, plan);
3023
+ preRollState.plan = plan;
3012
3024
  return plan;
3013
3025
  };
3014
3026
  const served = [];
@@ -3021,7 +3033,7 @@ var RtspRestreamer = class {
3021
3033
  const detectionIntent = session.isDetectionIntent();
3022
3034
  const preRollIntent = session.preRollIntent();
3023
3035
  if (preRollIntent !== null) {
3024
- const plan = planFor(preRollIntent);
3036
+ const plan = planFor();
3025
3037
  if (plan.active) {
3026
3038
  if (plan.overBound) {
3027
3039
  this.dropsWithheldSpan++;
@@ -3066,11 +3078,10 @@ var RtspRestreamer = class {
3066
3078
  }
3067
3079
  if (withheld > 0) this.reportDrops(false);
3068
3080
  if (served.length === 0) return;
3069
- /** Media actually delivered to this intent, or `-1` when none joined. */
3070
- const servedSpanMs = (intent) => {
3071
- const plan = preRollPlans.get(intent);
3072
- return plan === void 0 ? -1 : Math.round(plan.spanMs);
3073
- };
3081
+ /** Media actually delivered, or `-1` when nobody asked for history at all. */
3082
+ const servedSpanMs = preRollState.plan === null ? -1 : Math.round(preRollState.plan.spanMs);
3083
+ /** Which declared intents joined this burst — `0` is "none", not "off". */
3084
+ const intentsServed = new Set(servedPreRoll.values());
3074
3085
  this.logger?.info("restream prime (lat-probe)", {
3075
3086
  tags: { deviceId: this.deviceId },
3076
3087
  meta: {
@@ -3083,10 +3094,10 @@ var RtspRestreamer = class {
3083
3094
  boundPackets: bound.maxPackets,
3084
3095
  withheld,
3085
3096
  detectionIntentServed,
3086
- detectionPreRollMs: this.preRollSupplier?.("detection").preRollMs ?? 0,
3087
- recordingPreRollMs: this.preRollSupplier?.("recording").preRollMs ?? 0,
3088
- detectionPreRollSpanMs: servedSpanMs("detection"),
3089
- recordingPreRollSpanMs: servedSpanMs("recording"),
3097
+ prebufferMs: this.preRollSupplier?.().preRollMs ?? 0,
3098
+ preRollSpanMs: servedSpanMs,
3099
+ recordingIntentServed: intentsServed.has("recording"),
3100
+ detectionPreRollServed: intentsServed.has("detection"),
3090
3101
  preRollServed: servedPreRoll.size
3091
3102
  }
3092
3103
  });
@@ -3095,16 +3106,15 @@ var RtspRestreamer = class {
3095
3106
  prime: true,
3096
3107
  keyframeAuStart: false
3097
3108
  }));
3098
- const preRollItems = /* @__PURE__ */ new Map();
3099
- for (const intent of new Set(servedPreRoll.values())) preRollItems.set(intent, (preRollPlans.get(intent)?.packets ?? []).map((packet) => ({
3109
+ const preRollItems = servedPreRoll.size > 0 ? (preRollState.plan?.packets ?? []).map((packet) => ({
3100
3110
  packet,
3101
3111
  prime: true,
3102
3112
  keyframeAuStart: false
3103
- })));
3113
+ })) : [];
3104
3114
  for (const sessionId of served) {
3105
3115
  this.pendingKeyframe.delete(sessionId);
3106
3116
  const intent = servedPreRoll.get(sessionId);
3107
- this.startPrimeReplay(sessionId, intent ? preRollItems.get(intent) ?? items : items, intent ?? null);
3117
+ this.startPrimeReplay(sessionId, intent ? preRollItems : items, intent ?? null);
3108
3118
  }
3109
3119
  }
3110
3120
  /**
@@ -11533,8 +11543,29 @@ var StreamBroker = class StreamBroker {
11533
11543
  firstKeyframeReceived = false;
11534
11544
  /** Timer for push-mode stall detection. */
11535
11545
  pushStallTimer;
11536
- /** Whether pre-buffer counts as demand (keeps the stream alive even with 0 subscribers). */
11537
- _preBufferEnabled = true;
11546
+ /**
11547
+ * Whether the prebuffer holds this stream dialed with zero real subscribers.
11548
+ *
11549
+ * **Starts OFF, and only {@link setPrebuffer} ever writes it.** It used to
11550
+ * start `true`, which is how this broker came to hold a state the settings
11551
+ * screen contradicted: `stream-broker-manager` resolved the same question as
11552
+ * `enabled ?? false` and showed OFF, while an unconfigured broker was already
11553
+ * warm. Neither was "the current configuration" — there were two — and that
11554
+ * is the defect D381 retires. Unconfigured now means off; the manager applies
11555
+ * the resolved prebuffer to every broker it creates, so the honest default
11556
+ * (ON for a mains camera's warm profile) arrives from the one place that
11557
+ * decides it.
11558
+ */
11559
+ _preBufferEnabled = false;
11560
+ /**
11561
+ * The open prebuffer hold, or `null` when the prebuffer is off.
11562
+ *
11563
+ * A real object rather than a flag plus a synthetic row, so "who is keeping
11564
+ * camera 617 warm?" is answered by something that EXISTS: it has an id, it
11565
+ * has a start time, and `hasDemand()` consults it the way it consults every
11566
+ * other holder.
11567
+ */
11568
+ prebufferHold = null;
11538
11569
  /**
11539
11570
  * Seconds of AnnexB video retained SPECIFICALLY so a notification clip can be
11540
11571
  * cut around a past instant (`renderPreBufferClip`). 0 = no clip retention.
@@ -11601,26 +11632,22 @@ var StreamBroker = class StreamBroker {
11601
11632
  */
11602
11633
  rtpRingKeyframeMarks = [];
11603
11634
  /**
11604
- * Media time (ms) of history a DETECTION decode dial may be handed before the
11605
- * live edge — motion-start Stage 2. `0` (default) = off, and every path here
11606
- * then behaves exactly as it did before pre-roll existed. Resolved per stream
11607
- * by `resolvePreRollMs` and pushed by the manager.
11608
- */
11609
- detectionPreRollMs = 0;
11610
- /**
11611
- * Media time (ms) of history a RECORDING dial (`/recording`) is handed — the
11612
- * `events`-band recorder's ffmpeg, which is spawned AT the trigger and whose
11613
- * first frame is otherwise at best the live edge.
11635
+ * Media time (ms) of history this broker RETAINS and will hand to a consumer
11636
+ * that declared it attaches after the edge it needs to see.
11614
11637
  *
11615
- * A SECOND number, not a wider first one (D193). The two windows are set by
11616
- * the operator for different reasons and cost different things: detection's
11617
- * is a backlog a decoder must drain, recording's is bytes copied to disk.
11618
- * Raising one to serve the other would change every detection dial on the
11619
- * fleet to buy a recorder ten seconds — the coupling the 2026-08-20 census
11620
- * named before anything was turned on. Resolved by `resolveRecordingPreRollMs`
11621
- * and pushed by the manager.
11638
+ * ONE number (D381), derived from the ONE prebuffer configuration by
11639
+ * `prebufferRetentionMs` and pushed by the manager through
11640
+ * {@link setPrebuffer}. It replaced `detectionPreRollMs` and
11641
+ * `recordingPreRollMs`, which were two configurations of the same retention
11642
+ * that the operator had to keep consistent by hand — and a third,
11643
+ * `preBufferSec`, which sized an Annex-B ring that is never fed for an RTP
11644
+ * source and therefore retained nothing at all on every warm camera in the
11645
+ * fleet.
11646
+ *
11647
+ * A consumer never sets this and never asks for a number wider than it: a
11648
+ * request is capped at read time by {@link capPreRollRequestMs}.
11622
11649
  */
11623
- recordingPreRollMs = 0;
11650
+ prebufferMs = 0;
11624
11651
  /**
11625
11652
  * Effective join-burst bound for this stream's RTSP restream — the cluster
11626
11653
  * default, or this camera stream's override. Applies to the RESTREAM
@@ -11672,11 +11699,10 @@ var StreamBroker = class StreamBroker {
11672
11699
  if (logger) this.rtspRestreamer.setLogger(logger.child("rtsp"));
11673
11700
  this.rtspRestreamer.setRtpPreBufferSupplier(() => this.getRtpPreBuffer());
11674
11701
  this.rtspRestreamer.setPrimeBoundSupplier(() => this.joinBurstBound);
11675
- this.rtspRestreamer.setPreRollSupplier((intent) => {
11676
- const windowMs = intent === "recording" ? this.recordingPreRollMs : this.detectionPreRollMs;
11702
+ this.rtspRestreamer.setPreRollSupplier(() => {
11677
11703
  return {
11678
- packets: () => this.getRtpPreRollBuffer(windowMs),
11679
- preRollMs: windowMs
11704
+ packets: () => this.getRtpPreRollBuffer(),
11705
+ preRollMs: this.prebufferMs
11680
11706
  };
11681
11707
  });
11682
11708
  this.preBuffer = new EncodedRingBuffer(StreamBroker.DEFAULT_PRE_BUFFER_SEC);
@@ -12082,9 +12108,30 @@ var StreamBroker = class StreamBroker {
12082
12108
  }
12083
12109
  });
12084
12110
  }
12085
- /** Set whether pre-buffer counts as demand. When false and no other consumers, the broker suspends. */
12111
+ /**
12112
+ * Open or release the prebuffer HOLD — the session that keeps this stream
12113
+ * dialed with no real consumer attached.
12114
+ *
12115
+ * Opening it is what "prebuffer on" MEANS: there is a holder, it has a name
12116
+ * and a start time, and it appears in `listClients()` beside every other
12117
+ * holder. Re-arming an already-open hold keeps its `since`, so the operator
12118
+ * reads how long this camera has been held warm rather than how long ago the
12119
+ * settings were last re-applied.
12120
+ */
12086
12121
  setPreBufferEnabled(enabled) {
12122
+ if (enabled === this._preBufferEnabled) return;
12087
12123
  this._preBufferEnabled = enabled;
12124
+ this.prebufferHold = enabled ? {
12125
+ id: `prebuffer:${this.deviceId}`,
12126
+ since: Date.now()
12127
+ } : null;
12128
+ this.logger?.info(enabled ? "prebuffer hold opened" : "prebuffer hold released", {
12129
+ tags: { deviceId: this.numericDeviceId },
12130
+ meta: {
12131
+ brokerId: this.deviceId,
12132
+ retentionMs: this.prebufferMs
12133
+ }
12134
+ });
12088
12135
  this.checkDemand();
12089
12136
  }
12090
12137
  /** True when at least one consumer needs the stream alive. */
@@ -12095,7 +12142,7 @@ var StreamBroker = class StreamBroker {
12095
12142
  if (this.audioChunkPlane.subscriberCount > 0) return true;
12096
12143
  if (this.rtspRestreamer.getSessionCount() > 0) return true;
12097
12144
  if (this.pipeServer.getClientCount() > 0) return true;
12098
- if (this._preBufferEnabled) return true;
12145
+ if (this.prebufferHold !== null) return true;
12099
12146
  return false;
12100
12147
  }
12101
12148
  /**
@@ -12719,48 +12766,36 @@ var StreamBroker = class StreamBroker {
12719
12766
  this.rtpRingKeyframeMarks = [];
12720
12767
  }
12721
12768
  /**
12722
- * Configure how much history a DETECTION dial may be handed (Stage 2).
12769
+ * Install THE prebuffer for this stream — the one configuration that decides
12770
+ * whether this broker holds the source warm and how much history it keeps.
12723
12771
  *
12724
- * Also widens the decoder-seed ring so the PUSH (Annex-B) plane can honour
12725
- * the same number: that ring is what `getSeedPackets` replays into a
12726
- * freshly-armed decoder, and a 6 s retention cannot serve a 6 s pre-roll plus
12727
- * the GOP that carries it.
12728
- */
12729
- setDetectionPreRollMs(ms) {
12730
- const next = Number.isFinite(ms) && ms > 0 ? ms : 0;
12731
- if (next === this.detectionPreRollMs) return;
12732
- this.detectionPreRollMs = next;
12772
+ * Every ring this broker owns is sized from it, each fed by the source type
12773
+ * it belongs to: the source-RTP ring (RTSP cameras, the history a join burst
12774
+ * is cut from), the Annex-B `preBuffer` (push sources), and the decoder seed.
12775
+ * That is the correction this record exists for — the seconds used to size
12776
+ * ONLY the Annex-B ring, which is never fed for an RTP source, so on every
12777
+ * warm camera in this fleet the configured seconds retained nothing while two
12778
+ * other numbers quietly sized the history that did exist.
12779
+ */
12780
+ setPrebuffer(config) {
12781
+ const next = require_prebuffer.prebufferRetentionMs(config);
12782
+ this.setPreBufferEnabled(config.enabled);
12783
+ this.setPreBufferDuration(config.seconds);
12784
+ if (next === this.prebufferMs) return;
12785
+ this.prebufferMs = next;
12733
12786
  const seedSec = Math.max(StreamBroker.DECODER_SEED_BUFFER_SEC, Math.ceil(next / 1e3) + 2);
12734
12787
  this.decoderSeedBuffer.setDuration(seedSec);
12735
12788
  }
12736
12789
  /**
12737
- * Configure how much history a RECORDING dial is handed.
12790
+ * How far back the ring is kept — the prebuffer, and nothing else.
12738
12791
  *
12739
- * Touches the RETENTION (through {@link getPreRollRetentionMs}) and nothing
12740
- * else: no decoder seed, no change to what any other consumer is served.
12741
- */
12742
- setRecordingPreRollMs(ms) {
12743
- const next = Number.isFinite(ms) && ms > 0 ? ms : 0;
12744
- this.recordingPreRollMs = next;
12745
- }
12746
- /** The DETECTION pre-roll in force for this stream (ms). `0` = off. */
12747
- getDetectionPreRollMs() {
12748
- return this.detectionPreRollMs;
12749
- }
12750
- /** The RECORDING pre-roll in force for this stream (ms). `0` = off. */
12751
- getRecordingPreRollMs() {
12752
- return this.recordingPreRollMs;
12753
- }
12754
- /**
12755
- * How far back the ring is kept: the WIDER of the two windows.
12756
- *
12757
- * One retention serves both because the bytes are the same bytes — the ring
12758
- * is cut per intent at read time ({@link getRtpPreRollBuffer}), so a 10 s
12759
- * recording window does not hand a detection dial 10 s of backlog. Retaining
12760
- * per intent would be two rings of the same packets.
12792
+ * It used to be `max(detectionPreRollMs, recordingPreRollMs)`: a maximum over
12793
+ * CONFIGURATIONS, which is what made "how much history does camera 617 hold"
12794
+ * a question with two answers and no owner. There is one configuration now,
12795
+ * so the retention is it.
12761
12796
  */
12762
12797
  getPreRollRetentionMs() {
12763
- return Math.max(this.detectionPreRollMs, this.recordingPreRollMs);
12798
+ return this.prebufferMs;
12764
12799
  }
12765
12800
  /**
12766
12801
  * Byte budget for the EXTRA history pre-roll retains, over and above the
@@ -12794,7 +12829,7 @@ var StreamBroker = class StreamBroker {
12794
12829
  * both, so a fix to the boundary cannot land on only one plane.
12795
12830
  */
12796
12831
  getDecoderSeedPackets() {
12797
- if (this.detectionPreRollMs <= 0) return this.decoderSeedBuffer.getPackets();
12832
+ if (this.prebufferMs <= 0) return this.decoderSeedBuffer.getPackets();
12798
12833
  const all = this.decoderSeedBuffer.getAllPackets();
12799
12834
  if (all.length === 0) return [];
12800
12835
  const newestPts = all[all.length - 1].pts;
@@ -12806,7 +12841,7 @@ var StreamBroker = class StreamBroker {
12806
12841
  ageMs: newestPts - packet.pts
12807
12842
  });
12808
12843
  }
12809
- const selection = selectPreRollStart(marks, this.detectionPreRollMs);
12844
+ const selection = selectPreRollStart(marks, this.prebufferMs);
12810
12845
  return selection.startIndex < 0 ? [] : all.slice(selection.startIndex);
12811
12846
  }
12812
12847
  /** Retained keyframe AUs as ages in ms of media time behind `newestTs`. */
@@ -12876,8 +12911,9 @@ var StreamBroker = class StreamBroker {
12876
12911
  * when motion fired retained nothing, and the honest outcome there is the
12877
12912
  * Stage 0 burst rather than a pretend one.
12878
12913
  */
12879
- getRtpPreRollBuffer(windowMs) {
12914
+ getRtpPreRollBuffer(requestedMs) {
12880
12915
  if (!this.rtpRingHasKeyframe) return [];
12916
+ const windowMs = require_prebuffer.capPreRollRequestMs(requestedMs, this.prebufferMs);
12881
12917
  if (windowMs <= 0 || this.rtpRing.length === 0) return this.getRtpPreBuffer();
12882
12918
  const newestTs = this.rtpRing[this.rtpRing.length - 1].readUInt32BE(4);
12883
12919
  const selection = selectPreRollStart(this.rtpPreRollMarks(newestTs), windowMs);
@@ -12900,8 +12936,7 @@ var StreamBroker = class StreamBroker {
12900
12936
  tags: { deviceId: this.numericDeviceId },
12901
12937
  meta: {
12902
12938
  retentionMs,
12903
- detectionPreRollMs: this.detectionPreRollMs,
12904
- recordingPreRollMs: this.recordingPreRollMs,
12939
+ prebufferMs: this.prebufferMs,
12905
12940
  bitrateKbps: this.bitrateKbps,
12906
12941
  budgetBytes: budget.bytes
12907
12942
  }
@@ -13157,15 +13192,16 @@ var StreamBroker = class StreamBroker {
13157
13192
  subscribedAt: entry.subscribedAt,
13158
13193
  packetsDelivered: entry.packetsDelivered
13159
13194
  });
13160
- if (this._preBufferEnabled && encoded.length === 0) encoded.push({
13161
- id: "prebuffer-hold",
13195
+ const hold = this.prebufferHold;
13196
+ if (hold) encoded.push({
13197
+ id: hold.id,
13162
13198
  channel: "annexb",
13163
13199
  attribution: {
13164
13200
  kind: "warmup",
13165
13201
  userAgent: "prebuffer",
13166
13202
  media: "video"
13167
13203
  },
13168
- subscribedAt: this.startedAt,
13204
+ subscribedAt: hold.since,
13169
13205
  packetsDelivered: 0
13170
13206
  });
13171
13207
  return {
@@ -15081,7 +15117,7 @@ var StreamBrokerManager = class StreamBrokerManager {
15081
15117
  cachedIngestOwnerAt = 0;
15082
15118
  static INGEST_OWNER_CACHE_TTL_MS = 6e4;
15083
15119
  lastOwnershipReconcile = Promise.resolve();
15084
- defaultPreBufferSec = 10;
15120
+ defaultPreBufferSec = 5;
15085
15121
  /**
15086
15122
  * Cluster-wide default join-burst span bound (D57). `undefined` until the
15087
15123
  * addon applies its config, at which point every live broker is re-bounded.
@@ -15090,20 +15126,6 @@ var StreamBrokerManager = class StreamBrokerManager {
15090
15126
  * happened to own its ingest (D52/D56).
15091
15127
  */
15092
15128
  defaultJoinBurstMaxSpanMs;
15093
- /**
15094
- * Cluster-wide default pre-roll (ms) — motion-start Stage 2. Cluster-scoped
15095
- * for the same reason as the join-burst bound (a movable ingest role must not
15096
- * change a camera's behaviour), and 0 by default: pre-roll spends retained
15097
- * bytes on the ingest node, so it is turned on per camera that needs it.
15098
- */
15099
- defaultPreRollMs = 0;
15100
- /**
15101
- * Cluster-wide default RECORDING pre-roll (ms). `undefined` until the addon
15102
- * pushes its config — which is a different fact from `0` and must stay so:
15103
- * `0` is an operator turning the feature OFF, `undefined` is a boot that has
15104
- * not read the setting yet and takes `DEFAULT_RECORDING_PRE_ROLL_MS`.
15105
- */
15106
- defaultRecordingPreRollMs = void 0;
15107
15129
  webrtcServer = null;
15108
15130
  /**
15109
15131
  * brokerId → the remote-encoded adapter backing a non-owned camera's WebRTC
@@ -15709,8 +15731,14 @@ var StreamBrokerManager = class StreamBrokerManager {
15709
15731
  return always;
15710
15732
  }
15711
15733
  }
15734
+ /**
15735
+ * The cluster-wide prebuffer SECONDS — the only duration in this subsystem
15736
+ * (D381). Pushed into every live broker, because a broker created before the
15737
+ * edit would otherwise keep the old retention for the life of the stream.
15738
+ */
15712
15739
  setDefaultPreBufferSec(sec) {
15713
15740
  this.defaultPreBufferSec = sec;
15741
+ this.pushPrebufferToLiveBrokers();
15714
15742
  }
15715
15743
  /**
15716
15744
  * Set the cluster-wide default join-burst span bound and push the newly
@@ -15742,48 +15770,16 @@ var StreamBrokerManager = class StreamBrokerManager {
15742
15770
  }));
15743
15771
  }
15744
15772
  /**
15745
- * Set the cluster-wide default pre-roll and push the newly effective value
15746
- * into every live broker — same reasoning as the join-burst bound: a broker
15747
- * outlives the setting that configured it.
15773
+ * Set the cluster-wide default prebuffer SECONDS and push the newly
15774
+ * effective configuration into every live broker — a broker outlives the
15775
+ * setting that configured it, so an un-pushed edit would apply to cameras
15776
+ * published afterwards and to no others.
15748
15777
  */
15749
- setDefaultPreRollMs(ms) {
15750
- this.defaultPreRollMs = ms;
15778
+ pushPrebufferToLiveBrokers() {
15751
15779
  for (const [brokerId, broker] of this.brokers) {
15752
15780
  const parsed = parseBrokerId(brokerId);
15753
15781
  if (!parsed) continue;
15754
- this.applyPreRoll(broker, parsed.deviceId, parsed.camStreamId);
15755
- }
15756
- }
15757
- /**
15758
- * Resolve and install this stream's pre-roll (Stage 2). A `pull-http` relay
15759
- * — the terminal cameras — takes the cluster default as 0 (operator
15760
- * decision); an explicit per-stream override still reaches it.
15761
- */
15762
- applyPreRoll(broker, deviceId, camStreamId) {
15763
- const terminalRelay = this.cameraStreams.get(deviceId)?.get(camStreamId)?.kind === "pull-http";
15764
- broker.setDetectionPreRollMs(resolvePreRollMs({
15765
- camStreamId,
15766
- clusterMs: this.defaultPreRollMs,
15767
- overrides: this.deviceOverrides.get(deviceId)?.preRollMs,
15768
- terminalRelay
15769
- }));
15770
- broker.setRecordingPreRollMs(resolveRecordingPreRollMs({
15771
- camStreamId,
15772
- clusterMs: this.defaultRecordingPreRollMs,
15773
- terminalRelay,
15774
- batteryOperated: this.deviceHasFeature(deviceId, require_dist.DeviceFeature.BatteryOperated)
15775
- }));
15776
- }
15777
- /**
15778
- * Set the cluster-wide default RECORDING pre-roll and push it into every live
15779
- * broker — same reasoning as detection's: a broker outlives its setting.
15780
- */
15781
- setDefaultRecordingPreRollMs(ms) {
15782
- this.defaultRecordingPreRollMs = ms;
15783
- for (const [brokerId, broker] of this.brokers) {
15784
- const parsed = parseBrokerId(brokerId);
15785
- if (!parsed) continue;
15786
- this.applyPreRoll(broker, parsed.deviceId, parsed.camStreamId);
15782
+ this.applyPrebufferConfig(broker, parsed.deviceId, parsed.camStreamId);
15787
15783
  }
15788
15784
  }
15789
15785
  setEventBus(bus) {
@@ -17767,7 +17763,17 @@ var StreamBrokerManager = class StreamBrokerManager {
17767
17763
  }
17768
17764
  loadPersistedDeviceOverrides(overrides) {
17769
17765
  this.deviceOverrides.clear();
17770
- for (const [k, v] of overrides) this.deviceOverrides.set(k, v);
17766
+ for (const [deviceId, persisted] of overrides) {
17767
+ const { override, migrated, superseded } = migrateRetiredPreRollOverride(persisted);
17768
+ if (migrated.length > 0 || superseded.length > 0) this.logger.info("retired per-stream pre-roll folded into the prebuffer", {
17769
+ tags: { deviceId },
17770
+ meta: {
17771
+ migrated,
17772
+ superseded
17773
+ }
17774
+ });
17775
+ this.deviceOverrides.set(deviceId, override);
17776
+ }
17771
17777
  (async () => {
17772
17778
  for (const [deviceId, override] of overrides) {
17773
17779
  if (!override.derivedStreams) continue;
@@ -17799,19 +17805,6 @@ var StreamBrokerManager = class StreamBrokerManager {
17799
17805
  })();
17800
17806
  }
17801
17807
  /**
17802
- * Effective pre-buffer duration for a brokerId's profile.
17803
- *
17804
- * Priority: per-profile override → legacy single-value override →
17805
- * battery-aware default (0 when the device has BatteryOperated and
17806
- * no override) → addon default.
17807
- */
17808
- getEffectivePreBufferSec(deviceId, camStreamId, addonDefault, forceForDetection = false) {
17809
- const perStream = this.deviceOverrides.get(deviceId)?.preBuffer?.[camStreamId];
17810
- if (perStream) return perStream.enabled || forceForDetection ? perStream.seconds : 0;
17811
- if (this.deviceHasFeature(deviceId, require_dist.DeviceFeature.BatteryOperated)) return 0;
17812
- return addonDefault;
17813
- }
17814
- /**
17815
17808
  * Per-device streaming debug flag — controls verbose info-level
17816
17809
  * logging across the broker, WebRTC server, and session for one
17817
17810
  * device. Off by default.
@@ -17951,9 +17944,9 @@ var StreamBrokerManager = class StreamBrokerManager {
17951
17944
  const profile = findProfileForStream(assignment.map, camStreamId);
17952
17945
  const rtspEnabled = this.rtspProvider.isEnabled(brokerId);
17953
17946
  const rtspEntry = this.rtspProvider.getEntry(brokerId);
17954
- const perStream = override?.preBuffer?.[camStreamId];
17955
- const preBufferEnabled = perStream?.enabled ?? false;
17956
- const preBufferSec = perStream?.seconds ?? this.defaultPreBufferSec;
17947
+ const effectivePrebuffer = this.effectivePrebuffer(deviceId, camStreamId);
17948
+ const preBufferEnabled = effectivePrebuffer.enabled;
17949
+ const preBufferSec = effectivePrebuffer.enabled ? effectivePrebuffer.seconds : this.defaultPreBufferSec;
17957
17950
  const hwAccelValue = override?.hwaccel?.[camStreamId] ?? "copy";
17958
17951
  const sourceParserOptionSettings = sourceParserOptionsFor(sourceTypeForParser(stream.kind));
17959
17952
  const sourceParserField = sourceParserOptionSettings.length > 0 ? [{
@@ -18049,16 +18042,17 @@ var StreamBrokerManager = class StreamBrokerManager {
18049
18042
  style: "switch",
18050
18043
  key: `preBufferEnabled:${camStreamId}`,
18051
18044
  label: "Pre-buffer",
18052
- description: "Keep a rolling buffer for instant playback on events. Forced on when detection uses this stream.",
18053
- default: false,
18045
+ description: "Hold this stream dialed and keep the last few seconds of it, so anything that attaches after an event — a recording, a detection decode — starts BEFORE the event instead of at the live edge. This is the only history setting in the system: recordings and detection ask for what is kept here and never configure a window of their own. Costs a permanent connection to the camera and memory on the ingest node. On by default for a mains camera's main profile; off for a battery camera, which has nothing to hold between wakes.",
18046
+ default: preBufferEnabled,
18054
18047
  value: preBufferEnabled
18055
18048
  },
18056
18049
  {
18057
18050
  type: "slider",
18058
18051
  key: `preBufferSec:${camStreamId}`,
18059
18052
  label: "Pre-buffer Duration",
18053
+ description: "Seconds of history kept while the pre-buffer is on. A consumer is served this much or less — never more, and never more than the stream has actually been warm for.",
18060
18054
  min: 0,
18061
- max: 30,
18055
+ max: 15,
18062
18056
  step: 1,
18063
18057
  unit: "s",
18064
18058
  default: this.defaultPreBufferSec,
@@ -18078,19 +18072,6 @@ var StreamBrokerManager = class StreamBrokerManager {
18078
18072
  default: 0,
18079
18073
  value: override?.joinBurstMaxSpanMs?.[camStreamId] ?? 0
18080
18074
  },
18081
- {
18082
- type: "number",
18083
- key: `preRollMs:${camStreamId}`,
18084
- label: "Detection pre-roll",
18085
- 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).",
18086
- min: 0,
18087
- max: PRE_ROLL_MAX_MS,
18088
- step: 250,
18089
- unit: "ms",
18090
- span: 2,
18091
- default: 0,
18092
- value: override?.preRollMs?.[camStreamId] ?? 0
18093
- },
18094
18075
  {
18095
18076
  type: "select",
18096
18077
  key: `hwaccel:${camStreamId}`,
@@ -18299,36 +18280,29 @@ var StreamBrokerManager = class StreamBrokerManager {
18299
18280
  this.rtspProvider.setEnabled(brokerId, Boolean(value));
18300
18281
  continue;
18301
18282
  }
18302
- if (fieldKey.startsWith("preBufferEnabled:")) {
18303
- const camStreamId = fieldKey.slice(17);
18283
+ if (fieldKey.startsWith("preBufferEnabled:") || fieldKey.startsWith("preBufferSec:")) {
18284
+ const isSwitch = fieldKey.startsWith("preBufferEnabled:");
18285
+ const camStreamId = fieldKey.slice((isSwitch ? "preBufferEnabled:" : "preBufferSec:").length);
18304
18286
  overrideDirty = true;
18305
18287
  const pb = { ...nextOverride.preBuffer };
18306
- const current = pb[camStreamId] ?? this.defaultStreamPreBuffer(deviceId, camStreamId);
18307
- const enabled = Boolean(value);
18308
- pb[camStreamId] = {
18288
+ const current = pb[camStreamId] ?? this.effectivePrebuffer(deviceId, camStreamId);
18289
+ pb[camStreamId] = isSwitch ? {
18290
+ enabled: Boolean(value),
18291
+ seconds: current.seconds > 0 ? current.seconds : this.defaultPreBufferSec
18292
+ } : {
18309
18293
  ...current,
18310
- enabled
18294
+ seconds: typeof value === "number" && Number.isFinite(value) ? value : this.defaultPreBufferSec
18311
18295
  };
18312
18296
  nextOverride.preBuffer = pb;
18313
18297
  const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
18314
- if (broker) {
18315
- broker.setPreBufferDuration(enabled ? pb[camStreamId]?.seconds ?? this.defaultPreBufferSec : 0);
18316
- broker.setPreBufferEnabled(enabled);
18317
- }
18318
- continue;
18319
- }
18320
- if (fieldKey.startsWith("preBufferSec:")) {
18321
- const camStreamId = fieldKey.slice(13);
18322
- const seconds = typeof value === "number" && Number.isFinite(value) ? value : this.defaultPreBufferSec;
18323
- overrideDirty = true;
18324
- const pb = { ...nextOverride.preBuffer };
18325
- pb[camStreamId] = {
18326
- ...pb[camStreamId] ?? this.defaultStreamPreBuffer(deviceId, camStreamId),
18327
- seconds
18328
- };
18329
- nextOverride.preBuffer = pb;
18330
- const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
18331
- if (broker && pb[camStreamId]?.enabled !== false) broker.setPreBufferDuration(seconds);
18298
+ if (broker) broker.setPrebuffer(require_prebuffer.resolvePrebuffer({
18299
+ camStreamId,
18300
+ clusterSeconds: this.defaultPreBufferSec,
18301
+ overrides: pb,
18302
+ defaultWarm: this.isDefaultWarm(deviceId, camStreamId),
18303
+ batteryOperated: this.deviceHasFeature(deviceId, require_dist.DeviceFeature.BatteryOperated),
18304
+ terminalRelay: this.cameraStreams.get(deviceId)?.get(camStreamId)?.kind === "pull-http"
18305
+ }));
18332
18306
  continue;
18333
18307
  }
18334
18308
  if (fieldKey.startsWith("joinBurstMaxSpanMs:")) {
@@ -18346,22 +18320,6 @@ var StreamBrokerManager = class StreamBrokerManager {
18346
18320
  }));
18347
18321
  continue;
18348
18322
  }
18349
- if (fieldKey.startsWith("preRollMs:")) {
18350
- const camStreamId = fieldKey.slice(10);
18351
- overrideDirty = true;
18352
- const pr = { ...nextOverride.preRollMs };
18353
- if (typeof value === "number" && Number.isFinite(value) && value > 0) pr[camStreamId] = Math.min(PRE_ROLL_MAX_MS, Math.max(250, value));
18354
- else delete pr[camStreamId];
18355
- nextOverride.preRollMs = pr;
18356
- const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
18357
- if (broker) broker.setDetectionPreRollMs(resolvePreRollMs({
18358
- camStreamId,
18359
- clusterMs: this.defaultPreRollMs,
18360
- overrides: pr,
18361
- terminalRelay: this.cameraStreams.get(deviceId)?.get(camStreamId)?.kind === "pull-http"
18362
- }));
18363
- continue;
18364
- }
18365
18323
  if (fieldKey.startsWith("hwaccel:")) {
18366
18324
  const camStreamId = fieldKey.slice(8);
18367
18325
  overrideDirty = true;
@@ -18707,7 +18665,7 @@ var StreamBrokerManager = class StreamBrokerManager {
18707
18665
  if (current.map.high !== newMap.high) for (const camStreamId of [current.map.high, newMap.high]) {
18708
18666
  if (!camStreamId) continue;
18709
18667
  const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
18710
- if (broker) this.applyPreBufferConfig(broker, deviceId, camStreamId);
18668
+ if (broker) this.applyPrebufferConfig(broker, deviceId, camStreamId);
18711
18669
  }
18712
18670
  for (const [camId, prevCount] of oldPushConsumers) if ((newPushConsumers.get(camId) ?? 0) === 0 && prevCount > 0) this.emitCamStreamIdle(deviceId, camId);
18713
18671
  for (const [camId, nextCount] of newPushConsumers) if ((oldPushConsumers.get(camId) ?? 0) === 0 && nextCount > 0) {
@@ -18990,11 +18948,7 @@ var StreamBrokerManager = class StreamBrokerManager {
18990
18948
  const cam = this.cameraStreams.get(deviceId)?.get(camStreamId);
18991
18949
  if (!cam) return;
18992
18950
  const brokerId = brokerIdFor(deviceId, camStreamId);
18993
- const live = this.brokers.get(brokerId);
18994
- if (live) {
18995
- this.applyPreRoll(live, deviceId, camStreamId);
18996
- return;
18997
- }
18951
+ if (this.brokers.get(brokerId)) return;
18998
18952
  const existingEnsure = this.brokerEnsures.get(brokerId);
18999
18953
  if (existingEnsure) {
19000
18954
  await existingEnsure;
@@ -19041,10 +18995,9 @@ var StreamBrokerManager = class StreamBrokerManager {
19041
18995
  broker.setRestreamUrlResolver(() => this.rtspProvider.getEntry(brokerId)?.mutedUrl ?? null);
19042
18996
  broker.setFfmpegBinaryPath(this.ffmpegConfig.binaryPath);
19043
18997
  if (this.costRegistry !== null) broker.setCostRegistry(this.costRegistry);
19044
- this.applyPreBufferConfig(broker, deviceId, camStreamId);
18998
+ this.applyPrebufferConfig(broker, deviceId, camStreamId);
19045
18999
  this.applyAudioMuteConfig(broker, deviceId);
19046
19000
  this.applyJoinBurstBound(broker, deviceId, camStreamId);
19047
- this.applyPreRoll(broker, deviceId, camStreamId);
19048
19001
  this.applyClipRetention(deviceId);
19049
19002
  if (this.deviceOverrides.get(deviceId)?.streamingDebug) broker.setStreamingDebug(true);
19050
19003
  broker.setSourceProvider(() => {
@@ -19262,63 +19215,47 @@ var StreamBrokerManager = class StreamBrokerManager {
19262
19215
  return this.brokers.get(brokerIdFor(deviceId, camStreamId))?.getStats().audio?.codec ?? null;
19263
19216
  }
19264
19217
  /**
19265
- * Whether a stream is warmed BY DEFAULT (no explicit override): the device's
19266
- * `high` profile, on a non-battery camera, from a source that is worth
19267
- * keeping hot. Single source of truth used by both the broker-config default
19268
- * (`applyPreBufferConfig`) and the settings-patch override synthesis
19269
- * (`defaultStreamPreBuffer`) so a seconds-only patch never silently disables
19270
- * the high-profile warm default.
19218
+ * Whether a stream is warmed BY DEFAULT — the device's `high` profile.
19271
19219
  *
19272
- * `pull-http` is excluded. The warm default is not free storage — it is
19273
- * PERMANENT SYNTHETIC DEMAND: `StreamBroker.hasDemand()` returns true on
19274
- * `_preBufferEnabled` alone, so a warm stream is dialed forever regardless
19275
- * of subscribers. For an MJPEG relay that means the ingest transcode
19276
- * (`HTTP_MJPEG_H264_OUTPUT_ARGS` → libx264) runs forever too. Measured on
19277
- * the live hub: the terminal addon's glances relay burned ~12% of a core
19278
- * and held its PTY open for hours with zero viewers. The trade the warm
19279
- * default buys elsewhere — a seeded GOP instead of a ~5-8 s cold start —
19280
- * does not apply here: an MJPEG relay is cheap to re-dial (no camera
19281
- * handshake) and expensive to keep hot. An explicit operator ON override
19282
- * still warms it; this is a DEFAULT, not a ban.
19220
+ * ONE input to {@link resolvePrebuffer}, not a decision of its own. Battery
19221
+ * and `pull-http` used to be tested here as well AND in
19222
+ * `getEffectivePreBufferSec` AND in `resolveRecordingPreRollMs`: three
19223
+ * statements of one rule, which is how they came to be able to disagree
19224
+ * about a single camera. They are stated once now, in `prebuffer.ts`.
19283
19225
  */
19284
19226
  isDefaultWarm(deviceId, camStreamId) {
19285
- if (!(findProfileForStream(this.assignments.get(deviceId)?.map ?? {}, camStreamId) === "high")) return false;
19286
- if (this.deviceHasFeature(deviceId, require_dist.DeviceFeature.BatteryOperated)) return false;
19287
- return this.cameraStreams.get(deviceId)?.get(camStreamId)?.kind !== "pull-http";
19227
+ return findProfileForStream(this.assignments.get(deviceId)?.map ?? {}, camStreamId) === "high";
19288
19228
  }
19289
19229
  /**
19290
- * Default `StreamPreBuffer` for a brand-new override entry — mirrors
19291
- * the effective broker default: `enabled` reflects the high-profile warm
19292
- * default (`isDefaultWarm`), so a seconds-only patch on a warm high stream
19293
- * inherits `enabled: true` instead of silently forcing it OFF. Operators
19294
- * still override explicitly per-stream via the device-settings sub-tabs;
19295
- * detection / recording paths pass `forceForDetection` to
19296
- * `getEffectivePreBufferSec` when they need a buffered window irrespective
19297
- * of the operator toggle.
19298
- */
19299
- defaultStreamPreBuffer(deviceId, camStreamId) {
19300
- return {
19301
- enabled: this.isDefaultWarm(deviceId, camStreamId),
19302
- seconds: this.defaultPreBufferSec
19303
- };
19230
+ * THE prebuffer for a (device, stream) — the one answer every caller uses.
19231
+ *
19232
+ * The settings screen reads it to display, the patch handler reads it to
19233
+ * apply, and {@link applyPrebufferConfig} reads it to install. That is the
19234
+ * D315-shaped half of this record: what the operator is SHOWN is resolved by
19235
+ * the same function that decides what runs, so a switch can no longer report
19236
+ * off on a function that is on — which is exactly what the settings
19237
+ * contribution did while it answered `perStream?.enabled ?? false` and the
19238
+ * broker's own flag shipped `true`.
19239
+ */
19240
+ effectivePrebuffer(deviceId, camStreamId) {
19241
+ return require_prebuffer.resolvePrebuffer({
19242
+ camStreamId,
19243
+ clusterSeconds: this.defaultPreBufferSec,
19244
+ overrides: this.deviceOverrides.get(deviceId)?.preBuffer,
19245
+ defaultWarm: this.isDefaultWarm(deviceId, camStreamId),
19246
+ batteryOperated: this.deviceHasFeature(deviceId, require_dist.DeviceFeature.BatteryOperated),
19247
+ terminalRelay: this.cameraStreams.get(deviceId)?.get(camStreamId)?.kind === "pull-http"
19248
+ });
19304
19249
  }
19305
19250
  /**
19306
- * Apply the effective pre-buffer config to a fresh broker instance.
19307
- * Default behaviour: the HIGH profile stream is warm (see below);
19308
- * every other stream stays OFF — idle until the operator opts in or
19309
- * a detection / recording subscriber attaches. An explicit per-stream
19310
- * operator override always wins over both defaults.
19251
+ * Install the effective prebuffer on a broker. Idempotent — safe on every
19252
+ * publish, every re-publish and every settings edit, which is what lets a
19253
+ * camera discovered late (a battery flag that arrives after the first dial)
19254
+ * converge instead of holding the wrong retention for the life of the
19255
+ * process.
19311
19256
  */
19312
- applyPreBufferConfig(broker, deviceId, camStreamId) {
19313
- const perStream = this.deviceOverrides.get(deviceId)?.preBuffer?.[camStreamId];
19314
- if (perStream) {
19315
- broker.setPreBufferEnabled(perStream.enabled);
19316
- broker.setPreBufferDuration(perStream.enabled ? perStream.seconds : 0);
19317
- return;
19318
- }
19319
- const defaultWarm = this.isDefaultWarm(deviceId, camStreamId);
19320
- broker.setPreBufferEnabled(defaultWarm);
19321
- broker.setPreBufferDuration(defaultWarm ? this.defaultPreBufferSec : 0);
19257
+ applyPrebufferConfig(broker, deviceId, camStreamId) {
19258
+ broker.setPrebuffer(this.effectivePrebuffer(deviceId, camStreamId));
19322
19259
  }
19323
19260
  /** Push the persisted mute onto a broker. Idempotent — safe on every pass. */
19324
19261
  applyAudioMuteConfig(broker, deviceId) {
@@ -29658,12 +29595,10 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
29658
29595
  lastProbePersistAt = 0;
29659
29596
  constructor() {
29660
29597
  super({
29661
- defaultPreBufferSec: 10,
29598
+ defaultPreBufferSec: 5,
29662
29599
  rtspPort: 8554,
29663
29600
  catalogReconcileIntervalSec: 30,
29664
29601
  joinBurstMaxSpanMs: DEFAULT_PRIME_BURST_BOUND.maxSpanMs,
29665
- preRollMs: 0,
29666
- recordingPreRollMs: DEFAULT_RECORDING_PRE_ROLL_MS,
29667
29602
  binaryPath: "",
29668
29603
  hwaccel: "copy"
29669
29604
  });
@@ -29733,8 +29668,6 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
29733
29668
  this.brokerManager.setCostRegistry(this.costRegistry);
29734
29669
  this.brokerManager.setDefaultPreBufferSec(this.config.defaultPreBufferSec);
29735
29670
  this.brokerManager.setDefaultJoinBurstMaxSpanMs(this.config.joinBurstMaxSpanMs);
29736
- this.brokerManager.setDefaultPreRollMs(this.config.preRollMs);
29737
- this.brokerManager.setDefaultRecordingPreRollMs(this.config.recordingPreRollMs);
29738
29671
  this.brokerManager.setEventBus(this.ctx.eventBus);
29739
29672
  const localNode = this.ctx.kernel.localNodeId;
29740
29673
  const localNodeId = localNode ? localNode.includes("/") ? localNode.split("/")[0] : localNode : void 0;
@@ -30221,7 +30154,7 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
30221
30154
  */
30222
30155
  async purgeFlattenedSettingsKeys() {
30223
30156
  try {
30224
- await require_retire_root_keys.retireRootKeys({
30157
+ await require_prebuffer.retireRootKeys({
30225
30158
  store: this.ctx.api.settingsStore,
30226
30159
  addonId: STREAM_BROKER_ADDON_ID,
30227
30160
  logger: this.ctx.logger,
@@ -30256,11 +30189,11 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
30256
30189
  type: "number",
30257
30190
  key: "defaultPreBufferSec",
30258
30191
  label: "Pre-buffer Duration",
30259
- description: "Seconds of video to keep buffered before a detection event",
30260
- min: 0,
30261
- max: 30,
30192
+ description: "Seconds of history a warm stream keeps. This is the ONLY history setting in the system: an event recording and a detection decode are both served from it and neither configures a window of its own — each is handed this much or whatever the stream has actually been warm for, whichever is less. It costs memory on whichever node ingests the camera, roughly this many seconds of that stream’s bitrate. Turn the pre-buffer on or off per camera stream in the camera’s Streams settings.",
30193
+ min: 1,
30194
+ max: 15,
30262
30195
  step: 1,
30263
- default: 10,
30196
+ default: 5,
30264
30197
  unit: "s"
30265
30198
  },
30266
30199
  {
@@ -30305,28 +30238,6 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
30305
30238
  default: DEFAULT_PRIME_BURST_BOUND.maxSpanMs,
30306
30239
  unit: "ms"
30307
30240
  },
30308
- {
30309
- type: "number",
30310
- key: "preRollMs",
30311
- label: "Detection pre-roll",
30312
- 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.",
30313
- min: 0,
30314
- max: PRE_ROLL_MAX_MS,
30315
- step: 250,
30316
- default: 0,
30317
- unit: "ms"
30318
- },
30319
- {
30320
- type: "number",
30321
- key: "recordingPreRollMs",
30322
- label: "Recording pre-roll",
30323
- 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.",
30324
- min: 0,
30325
- max: RECORDING_PRE_ROLL_MAX_MS,
30326
- step: 500,
30327
- default: DEFAULT_RECORDING_PRE_ROLL_MS,
30328
- unit: "ms"
30329
- },
30330
30241
  {
30331
30242
  type: "number",
30332
30243
  key: "catalogReconcileIntervalSec",