@camstack/addon-pipeline 1.2.102 → 1.2.104

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