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