@camstack/addon-pipeline 1.2.103 → 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 +598 -82
  30. package/dist/stream-broker/index.mjs +597 -81
  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
@@ -2,8 +2,9 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-C11WuNUP.js");
6
- const require_remote_restream = require("../remote-restream-BYbAsgUf.js");
5
+ const require_dist = require("../dist-gLAVhbvO.js");
6
+ const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
7
+ const require_remote_restream = require("../remote-restream-CO36Sr30.js");
7
8
  const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
8
9
  let node_crypto = require("node:crypto");
9
10
  node_crypto = require_dist.__toESM(node_crypto, 1);
@@ -1433,6 +1434,35 @@ var TS_COPYABLE_AUDIO_CODECS = new Set(["aac"]);
1433
1434
  * neither gopFrames nor fps. Matches BASE_LIVE_EGRESS_PROFILE's intent.
1434
1435
  */
1435
1436
  var DERIVED_DEFAULT_GOP_FRAMES = 25;
1437
+ /**
1438
+ * No B-frames, ever, unless the profile asks for them by name.
1439
+ *
1440
+ * A derived stream's carrier is MPEG-TS → {@link MpegTsDemux} → the broker →
1441
+ * RTP (an RTSP restream to the recorder, a WebRTC leg to a viewer). Every hop
1442
+ * has to carry the reorder delay and RTP has no field for it at all — so
1443
+ * reordering costs latency on the whole chain and buys compression nobody
1444
+ * asked for at 640x360.
1445
+ */
1446
+ var DERIVED_DEFAULT_BF = 0;
1447
+ /**
1448
+ * The encode preset and tune the carrier pins when the profile names neither.
1449
+ *
1450
+ * `veryfast` + `zerolatency` is `BASE_LIVE_EGRESS_PROFILE`'s pairing, and it is
1451
+ * the pairing that removes x264's lookahead: `preset medium` carries
1452
+ * `rc-lookahead=40`, which at 25 fps is 1.6 SECONDS the encoder holds before it
1453
+ * emits anything. See this module's `withCarrierDefaults` for the measurement.
1454
+ */
1455
+ var DERIVED_DEFAULT_PRESET = "veryfast";
1456
+ var DERIVED_DEFAULT_TUNE = "zerolatency";
1457
+ /**
1458
+ * The bit density a derived tier is capped at, expressed as the shared
1459
+ * live-egress profile it is scaled from: `BASE_LIVE_EGRESS_PROFILE` spends
1460
+ * 2500 kbps on 1280x720 at 25 fps. Scaling that per pixel-per-second gives
1461
+ * 625 kbps at 640x360@25 — and a 10 s segment of 625 kbps is ~780 KB, half of
1462
+ * the scrub's 1.5 MB whole-segment ceiling (D163).
1463
+ */
1464
+ var DERIVED_BITRATE_REFERENCE_KBPS = 2500;
1465
+ var DERIVED_BITRATE_REFERENCE_PIXELS_PER_SECOND = 1280 * 720 * 25;
1436
1466
  var DERIVED_AAC_RECODE = {
1437
1467
  codec: "aac",
1438
1468
  channels: 1
@@ -1486,12 +1516,66 @@ function resolveDerivedAudio(profileAudio, sourceAudioCodec) {
1486
1516
  * own — the MPEG-TS carrier, not the operator, decides whether the source
1487
1517
  * track can be copied.
1488
1518
  */
1519
+ /**
1520
+ * The rate ceiling for a geometry, or `undefined` when the profile named no
1521
+ * geometry to scale by (there is then nothing to derive a number from, and a
1522
+ * guessed cap on an unknown resolution is worse than none).
1523
+ */
1524
+ function derivedBitrateCeilingKbps(video) {
1525
+ const { width, height } = video;
1526
+ if (width === void 0 || height === void 0) return void 0;
1527
+ const pixelsPerSecond = width * height * (video.fps ?? DERIVED_DEFAULT_GOP_FRAMES);
1528
+ return Math.round(DERIVED_BITRATE_REFERENCE_KBPS * pixelsPerSecond / DERIVED_BITRATE_REFERENCE_PIXELS_PER_SECOND);
1529
+ }
1530
+ /**
1531
+ * Fill the encode knobs the CARRIER owns, leaving every one the profile names
1532
+ * untouched. Same rule as the audio decision above: an operator states intent,
1533
+ * the carrier states what its own hops can survive.
1534
+ *
1535
+ * - **GOP.** x264's own default is keyint=250 — TEN seconds at the fleet's
1536
+ * 25 fps. Measured live (camera dispensa, 2026-08-19): 1 I-frame in 250 on a
1537
+ * recorded low segment, and a WebRTC viewer joining mid-GOP decoded
1538
+ * reference-less P-frames for up to 10 s — the smear artifacts on every live
1539
+ * open of a derived tier, grid tiles included.
1540
+ *
1541
+ * - **B-frames, preset, tune.** Measured live (camera 1438, 2026-08-20) by
1542
+ * reading the camera's burnt-in OSD out of RECORDED segments and comparing
1543
+ * it against the wall clock the recorder named the file with. Same camera,
1544
+ * so the same OSD clock; mid-roll samples only, because the first segment of
1545
+ * a triggered roll carries pre-roll and is older than its own name:
1546
+ *
1547
+ * native `high` (-c:v copy): -1s -1s -1s -2s
1548
+ * derived `low` (transcode): -3s -3s -3s -3s
1549
+ *
1550
+ * ~2 s, and it is the encoder holding frames back: `preset medium` carries
1551
+ * `rc-lookahead=40` (1.6 s at 25 fps) on top of `bframes=3` with b-adapt and
1552
+ * b-pyramid. The recorder stamps a segment with the wall clock at WRITE
1553
+ * time, so all of it becomes a MISLABEL: seeking a derived tier to an
1554
+ * event's timestamp lands ~2 s past the event while the native tier of the
1555
+ * same camera lands on it.
1556
+ *
1557
+ * - **Bitrate.** A CEILING (`-maxrate`, no `-b:v` — see `buildRateControlArgs`,
1558
+ * whose default rate control is `cap`), because a derived tier is RECORDED
1559
+ * and the scrub reads a `low` segment whole only while it is under 1.5 MB
1560
+ * (D163). Uncapped CRF has no relationship to that ceiling: 1438/low
1561
+ * measured 537–941 kbps at 640x360@25 against 142 kbps for camera 615's
1562
+ * NATIVE low at the identical geometry, peaking at 1.19 MB in a 10 s
1563
+ * segment. Quiet scenes still spend less than the cap.
1564
+ */
1565
+ function withCarrierDefaults(video) {
1566
+ const bitrateKbps = video.bitrateKbps ?? derivedBitrateCeilingKbps(video);
1567
+ return {
1568
+ ...video,
1569
+ gopFrames: video.gopFrames ?? video.fps ?? DERIVED_DEFAULT_GOP_FRAMES,
1570
+ bf: video.bf ?? DERIVED_DEFAULT_BF,
1571
+ preset: video.preset ?? DERIVED_DEFAULT_PRESET,
1572
+ tune: video.tune ?? DERIVED_DEFAULT_TUNE,
1573
+ ...bitrateKbps !== void 0 ? { bitrateKbps } : {}
1574
+ };
1575
+ }
1489
1576
  function buildDerivedTranscodeArgs(input) {
1490
1577
  const audio = resolveDerivedAudio(input.profile.audio, input.sourceAudioCodec);
1491
- const video = input.profile.video.gopFrames !== void 0 ? input.profile.video : {
1492
- ...input.profile.video,
1493
- gopFrames: input.profile.video.fps ?? DERIVED_DEFAULT_GOP_FRAMES
1494
- };
1578
+ const video = withCarrierDefaults(input.profile.video);
1495
1579
  return {
1496
1580
  args: require_dist.buildFfmpegArgs(require_dist.invocationFromEncodeProfile({
1497
1581
  profile: {
@@ -1557,11 +1641,34 @@ var NOTHING = {
1557
1641
  ageMs: 0,
1558
1642
  gopsBack: 0
1559
1643
  };
1560
- /** A configured pre-roll in ms, or `0` when the input carries no instruction. */
1561
- function usablePreRoll(value) {
1644
+ /**
1645
+ * A configured pre-roll in ms, or `0` when the input carries no instruction.
1646
+ *
1647
+ * `maxMs` is the ceiling of the WINDOW being resolved: detection's
1648
+ * ({@link PRE_ROLL_MAX_MS}) bounds a decoder's drain, recording's
1649
+ * ({@link RECORDING_PRE_ROLL_MAX_MS}) bounds the recorder's keep gate. One
1650
+ * clamp for both so a new window cannot arrive with its own rounding rules.
1651
+ */
1652
+ function clampPreRoll(value, maxMs) {
1562
1653
  if (value === void 0) return 0;
1563
1654
  if (!Number.isFinite(value) || value <= 0) return 0;
1564
- return Math.min(PRE_ROLL_MAX_MS, Math.max(250, value));
1655
+ return Math.min(maxMs, Math.max(250, value));
1656
+ }
1657
+ /**
1658
+ * {@link clampPreRoll} at the WIDEST ceiling any window in this system may
1659
+ * carry.
1660
+ *
1661
+ * Used by the three functions that operate on a window ALREADY resolved by
1662
+ * `resolvePreRollMs` / `resolveRecordingPreRollMs` — the selection, the packet
1663
+ * ceiling and the span bound. Clamping those at the DETECTION ceiling was
1664
+ * correct while there was one window and became a silent 6 s truncation of
1665
+ * every recording burst the moment there were two: the selector would refuse
1666
+ * the keyframe the recorder asked for, and the span bound would withhold the
1667
+ * burst for being as wide as the operator configured — D192's failure, one
1668
+ * layer down.
1669
+ */
1670
+ function usablePreRoll(value) {
1671
+ return clampPreRoll(value, RECORDING_PRE_ROLL_MAX_MS);
1565
1672
  }
1566
1673
  /**
1567
1674
  * Choose the keyframe access unit a pre-roll burst starts at.
@@ -1599,6 +1706,104 @@ function selectPreRollStart(marks, preRollMs) {
1599
1706
  };
1600
1707
  }
1601
1708
  /**
1709
+ * Ceiling for the RECORDING window.
1710
+ *
1711
+ * Wider than {@link PRE_ROLL_MAX_MS} because the two windows are bounded by
1712
+ * different things. Detection's ceiling is the backlog a decoder must drain
1713
+ * before it reaches the live edge — media the analyser pays for frame by frame.
1714
+ * A recorder drains nothing: ffmpeg copies the burst to disk. What bounds THIS
1715
+ * number is the recorder's retroactive keep window (`preBufferSec`), because
1716
+ * footage the broker hands the writer from before that bound is deleted by the
1717
+ * keep gate the moment it finalizes — the divergence D191/D192 exist to forbid.
1718
+ * `packages/addon-pipeline/src/recorder/addon/__tests__/band-decision.spec.ts`
1719
+ * fails if this is ever raised past the shipped keep window.
1720
+ */
1721
+ var RECORDING_PRE_ROLL_MAX_MS = 15e3;
1722
+ /**
1723
+ * Shipped RECORDING pre-roll on a mains camera. Operator decision, 2026-08-20.
1724
+ *
1725
+ * Unlike detection's, this one ships ON. The audit of the same day measured the
1726
+ * reason: on camera 1438 the footage began AFTER the motion in 60 % of
1727
+ * episodes, because the writer is spawned at the trigger and ffmpeg's first
1728
+ * frame is at best the live edge. A recording feature whose media starts after
1729
+ * the event is not a tuning question.
1730
+ */
1731
+ var DEFAULT_RECORDING_PRE_ROLL_MS = 1e4;
1732
+ /**
1733
+ * Budget when the broker has not measured a bitrate for this stream yet — the
1734
+ * historical flat cap, kept verbatim as the honest "we do not know".
1735
+ *
1736
+ * `bitrateKbps` is 0 for the first stats window after every dial and after
1737
+ * every reset, so this is the value in force exactly while the ring is filling
1738
+ * for the first time.
1739
+ */
1740
+ var PRE_ROLL_UNKNOWN_BITRATE_BYTES = 8 * 1024 * 1024;
1741
+ /**
1742
+ * Absolute per-stream ceiling on retained pre-roll history.
1743
+ *
1744
+ * The bitrate-derived budget is what the window COSTS; this is the most any one
1745
+ * stream may spend regardless. 32 MiB is 4x the flat cap it replaces and covers
1746
+ * 15 s of a 16 Mbps stream with headroom; past it the operator has configured a
1747
+ * window this node cannot afford on that camera, and the broker says so rather
1748
+ * than delivering silently less (D192).
1749
+ */
1750
+ var PRE_ROLL_ABSOLUTE_MAX_BYTES = 32 * 1024 * 1024;
1751
+ /**
1752
+ * Headroom over the OBSERVED mean bitrate.
1753
+ *
1754
+ * `BrokerStatsSchema.bitrateKbps` is a mean over the stats window; a GOP
1755
+ * carrying an IDR plus a scene change is well above it. Budgeting at exactly
1756
+ * the mean would drop the oldest GOP on every busy second, so the window would
1757
+ * be the one the operator asked for only while nothing moved — which is the
1758
+ * opposite of when it is needed.
1759
+ */
1760
+ var PRE_ROLL_BITRATE_HEADROOM = 1.5;
1761
+ /**
1762
+ * The byte budget for the history a `windowMs` pre-roll needs on a stream
1763
+ * observed at `observedKbps`.
1764
+ *
1765
+ * Replaces a FLAT 8 MiB cap. That constant was a promise the feature could not
1766
+ * keep in either direction: above ~6.5 Mbps it silently truncated the window
1767
+ * (a 4K main stream asked for 10 s and got 6), and below it, it authorised a
1768
+ * doorbell at 512 kbps to hold 8 MiB — 16x what its own window weighs.
1769
+ */
1770
+ function preRollBudgetBytes(windowMs, observedKbps) {
1771
+ if (!Number.isFinite(windowMs) || windowMs <= 0) return {
1772
+ bytes: 0,
1773
+ clamped: false
1774
+ };
1775
+ if (!Number.isFinite(observedKbps) || observedKbps <= 0) return {
1776
+ bytes: PRE_ROLL_UNKNOWN_BITRATE_BYTES,
1777
+ clamped: false
1778
+ };
1779
+ const raw = Math.round(windowMs * observedKbps * PRE_ROLL_BITRATE_HEADROOM / 8);
1780
+ if (raw > 33554432) return {
1781
+ bytes: PRE_ROLL_ABSOLUTE_MAX_BYTES,
1782
+ clamped: true
1783
+ };
1784
+ return {
1785
+ bytes: raw,
1786
+ clamped: false
1787
+ };
1788
+ }
1789
+ /**
1790
+ * The RECORDING window a broker retains and serves to a `/recording` dial.
1791
+ *
1792
+ * Precedence: stream override -> battery / terminal relay (0) -> cluster
1793
+ * default -> {@link DEFAULT_RECORDING_PRE_ROLL_MS}. An override wins over the
1794
+ * battery rule on purpose: the rule is a DEFAULT about where retention is
1795
+ * wasted, and an operator who typed a number for that stream has answered the
1796
+ * question the default was guessing at.
1797
+ */
1798
+ function resolveRecordingPreRollMs(input) {
1799
+ const override = clampPreRoll(input.overrides?.[input.camStreamId], RECORDING_PRE_ROLL_MAX_MS);
1800
+ if (override > 0) return override;
1801
+ if (input.batteryOperated === true) return 0;
1802
+ if (input.terminalRelay === true) return 0;
1803
+ if (input.clusterMs === void 0) return DEFAULT_RECORDING_PRE_ROLL_MS;
1804
+ return clampPreRoll(input.clusterMs, RECORDING_PRE_ROLL_MAX_MS);
1805
+ }
1806
+ /**
1602
1807
  * Combine the cluster default and the per-camera-stream override into the one
1603
1808
  * pre-roll a broker applies. Precedence: stream override → cluster default →
1604
1809
  * OFF. Each layer is validated independently, so a bad override falls back to a
@@ -1609,10 +1814,10 @@ function selectPreRollStart(marks, preRollMs) {
1609
1814
  * coincide on every unconfigured stream.
1610
1815
  */
1611
1816
  function resolvePreRollMs(input) {
1612
- const override = usablePreRoll(input.overrides?.[input.camStreamId]);
1817
+ const override = clampPreRoll(input.overrides?.[input.camStreamId], PRE_ROLL_MAX_MS);
1613
1818
  if (override > 0) return override;
1614
1819
  if (input.terminalRelay === true) return 0;
1615
- return usablePreRoll(input.clusterMs);
1820
+ return clampPreRoll(input.clusterMs, PRE_ROLL_MAX_MS);
1616
1821
  }
1617
1822
  /**
1618
1823
  * The packet ceiling for a burst carrying `preRollMs` of pre-roll.
@@ -2282,8 +2487,13 @@ var RtspRestreamer = class {
2282
2487
  * A second supplier and not a widening of {@link rtpPreBufferSupplier},
2283
2488
  * because the two feed different populations: that one is also the WebRTC
2284
2489
  * bootstrap and every ordinary RTSP join, and handing them history is the
2285
- * D57 failure. Only a session that declared the detection intent on its dial
2286
- * path is served from this one.
2490
+ * D57 failure. Only a session that DECLARED an intent on its dial path is
2491
+ * served from this one.
2492
+ *
2493
+ * Takes the intent, because the two windows are two numbers (D193): a
2494
+ * detection dial drains its backlog frame by frame and a recorder copies it
2495
+ * to disk, so the operator sets them apart. One supplier keyed by intent, and
2496
+ * not two suppliers, so a third intent cannot arrive with its own wiring.
2287
2497
  *
2288
2498
  * Pulled per join for the same reason as the bound: a broker outlives the
2289
2499
  * setting.
@@ -2577,36 +2787,74 @@ var RtspRestreamer = class {
2577
2787
  if (this.sessions.get(sessionId)?.isPlaying()) ready.push(sessionId);
2578
2788
  }
2579
2789
  if (ready.length === 0) return;
2580
- const preRoll = this.preRollSupplier?.();
2581
- const preRollPackets = fromRing && preRoll && preRoll.preRollMs > 0 && preRoll.packets.length > primePackets.length ? preRoll.packets.map((data) => ({
2582
- data,
2583
- marker: (data[1] & 128) !== 0
2584
- })) : [];
2585
- const preRollActive = preRollPackets.length > 0;
2586
- const preRollSpanMs = preRollActive ? (preRollPackets[preRollPackets.length - 1].data.readUInt32BE(4) - preRollPackets[0].data.readUInt32BE(4) | 0) / 90 : 0;
2790
+ const preRollPlans = /* @__PURE__ */ new Map();
2587
2791
  const bound = this.currentPrimeBound();
2588
2792
  const overSpan = spanMs > bound.maxSpanMs;
2589
2793
  const overPackets = primePackets.length > bound.maxPackets;
2590
- const preRollBound = preRollActive ? preRollSpanBound(bound, preRoll?.preRollMs ?? 0) : bound;
2591
- const preRollOverBound = preRollActive ? preRollSpanMs > preRollBound.maxSpanMs || preRollPackets.length > preRollBound.maxPackets : false;
2794
+ /**
2795
+ * Resolve (and memoize) the plan for one intent.
2796
+ *
2797
+ * The bound a PRE-ROLL burst is held to is D57's, widened to the pre-roll
2798
+ * the operator configured FOR THAT INTENT, with the packet ceiling scaled
2799
+ * by the same factor so the defensive backstop stays a rate. A window WIDER
2800
+ * than that is still withheld — the selector cannot produce one, but a long
2801
+ * current GOP or a supplier bug can, and D57's remedy (join clean at the
2802
+ * next live keyframe) is the right one for a burst nobody asked for.
2803
+ */
2804
+ const planFor = (intent) => {
2805
+ const cached = preRollPlans.get(intent);
2806
+ if (cached) return cached;
2807
+ const burst = this.preRollSupplier?.(intent);
2808
+ const window = fromRing && burst && burst.preRollMs > 0 ? burst.packets() : [];
2809
+ const packets = window.length > primePackets.length ? window.map((data) => ({
2810
+ data,
2811
+ marker: (data[1] & 128) !== 0
2812
+ })) : [];
2813
+ const windowSpanMs = packets.length > 0 ? (packets[packets.length - 1].data.readUInt32BE(4) - packets[0].data.readUInt32BE(4) | 0) / 90 : 0;
2814
+ const windowBound = packets.length > 0 ? preRollSpanBound(bound, burst?.preRollMs ?? 0) : bound;
2815
+ const plan = {
2816
+ packets,
2817
+ active: packets.length > 0,
2818
+ configuredMs: burst?.preRollMs ?? 0,
2819
+ spanMs: windowSpanMs,
2820
+ overBound: packets.length > 0 && (windowSpanMs > windowBound.maxSpanMs || packets.length > windowBound.maxPackets)
2821
+ };
2822
+ preRollPlans.set(intent, plan);
2823
+ return plan;
2824
+ };
2592
2825
  const served = [];
2593
- const servedPreRoll = /* @__PURE__ */ new Set();
2826
+ const servedPreRoll = /* @__PURE__ */ new Map();
2594
2827
  let withheld = 0;
2595
2828
  let detectionIntentServed = 0;
2596
2829
  for (const sessionId of ready) {
2597
2830
  const session = this.sessions.get(sessionId);
2598
2831
  if (!session) continue;
2599
2832
  const detectionIntent = session.isDetectionIntent();
2600
- if (detectionIntent && preRollActive) {
2601
- if (preRollOverBound) {
2602
- this.dropsWithheldSpan++;
2603
- withheld++;
2833
+ const preRollIntent = session.preRollIntent();
2834
+ if (preRollIntent !== null) {
2835
+ const plan = planFor(preRollIntent);
2836
+ if (plan.active) {
2837
+ if (plan.overBound) {
2838
+ this.dropsWithheldSpan++;
2839
+ withheld++;
2840
+ continue;
2841
+ }
2842
+ servedPreRoll.set(sessionId, preRollIntent);
2843
+ served.push(sessionId);
2844
+ this.logger?.info("restream pre-roll served", {
2845
+ tags: { deviceId: this.deviceId },
2846
+ meta: {
2847
+ streamName: this.streamName,
2848
+ sessionId,
2849
+ intent: preRollIntent,
2850
+ consumer: session.getUserAgent() ?? "unknown",
2851
+ configuredMs: plan.configuredMs,
2852
+ spanMs: Math.round(plan.spanMs),
2853
+ packets: plan.packets.length
2854
+ }
2855
+ });
2604
2856
  continue;
2605
2857
  }
2606
- detectionIntentServed++;
2607
- servedPreRoll.add(sessionId);
2608
- served.push(sessionId);
2609
- continue;
2610
2858
  }
2611
2859
  if (session.isMuted() && !detectionIntent) {
2612
2860
  if (!fromRing || spanMs > LIVE_EDGE_MAX_SPAN_MS) {
@@ -2625,6 +2873,11 @@ var RtspRestreamer = class {
2625
2873
  }
2626
2874
  if (withheld > 0) this.reportDrops(false);
2627
2875
  if (served.length === 0) return;
2876
+ /** Media actually delivered to this intent, or `-1` when none joined. */
2877
+ const servedSpanMs = (intent) => {
2878
+ const plan = preRollPlans.get(intent);
2879
+ return plan === void 0 ? -1 : Math.round(plan.spanMs);
2880
+ };
2628
2881
  this.logger?.info("restream prime (lat-probe)", {
2629
2882
  tags: { deviceId: this.deviceId },
2630
2883
  meta: {
@@ -2637,9 +2890,10 @@ var RtspRestreamer = class {
2637
2890
  boundPackets: bound.maxPackets,
2638
2891
  withheld,
2639
2892
  detectionIntentServed,
2640
- preRollMs: preRoll?.preRollMs ?? 0,
2641
- preRollSpanMs: preRollActive ? Math.round(preRollSpanMs) : 0,
2642
- preRollPackets: preRollPackets.length,
2893
+ detectionPreRollMs: this.preRollSupplier?.("detection").preRollMs ?? 0,
2894
+ recordingPreRollMs: this.preRollSupplier?.("recording").preRollMs ?? 0,
2895
+ detectionPreRollSpanMs: servedSpanMs("detection"),
2896
+ recordingPreRollSpanMs: servedSpanMs("recording"),
2643
2897
  preRollServed: servedPreRoll.size
2644
2898
  }
2645
2899
  });
@@ -2648,14 +2902,16 @@ var RtspRestreamer = class {
2648
2902
  prime: true,
2649
2903
  keyframeAuStart: false
2650
2904
  }));
2651
- const preRollItems = servedPreRoll.size > 0 ? preRollPackets.map((packet) => ({
2905
+ const preRollItems = /* @__PURE__ */ new Map();
2906
+ for (const intent of new Set(servedPreRoll.values())) preRollItems.set(intent, (preRollPlans.get(intent)?.packets ?? []).map((packet) => ({
2652
2907
  packet,
2653
2908
  prime: true,
2654
2909
  keyframeAuStart: false
2655
- })) : [];
2910
+ })));
2656
2911
  for (const sessionId of served) {
2657
2912
  this.pendingKeyframe.delete(sessionId);
2658
- this.startPrimeReplay(sessionId, servedPreRoll.has(sessionId) ? preRollItems : items);
2913
+ const intent = servedPreRoll.get(sessionId);
2914
+ this.startPrimeReplay(sessionId, intent ? preRollItems.get(intent) ?? items : items, intent ?? null);
2659
2915
  }
2660
2916
  }
2661
2917
  /**
@@ -2668,7 +2924,7 @@ var RtspRestreamer = class {
2668
2924
  * session that went away — aborts the whole replay rather than punching holes
2669
2925
  * in the GOP: the session is left latched and rejoins at the next keyframe AU.
2670
2926
  */
2671
- startPrimeReplay(sessionId, items) {
2927
+ startPrimeReplay(sessionId, items, preRollIntent) {
2672
2928
  const replay = new PacedRtpReplay((item) => {
2673
2929
  const session = this.sessions.get(sessionId);
2674
2930
  if (!session) {
@@ -2684,6 +2940,20 @@ var RtspRestreamer = class {
2684
2940
  return false;
2685
2941
  }, (stats) => {
2686
2942
  this.activeReplays.delete(sessionId);
2943
+ if (preRollIntent !== null) this.logger?.info("restream pre-roll replay done", {
2944
+ tags: { deviceId: this.deviceId },
2945
+ meta: {
2946
+ streamName: this.streamName,
2947
+ sessionId,
2948
+ intent: preRollIntent,
2949
+ consumer: this.sessions.get(sessionId)?.getUserAgent() ?? "unknown",
2950
+ packets: stats.packets,
2951
+ sent: stats.sent,
2952
+ aborted: stats.aborted,
2953
+ enqueuedLive: stats.enqueuedLive,
2954
+ durationMs: stats.durationMs
2955
+ }
2956
+ });
2687
2957
  this.logger?.debug("restream prime drained", {
2688
2958
  tags: { deviceId: this.deviceId },
2689
2959
  meta: {
@@ -3032,6 +3302,17 @@ var RtspSession = class {
3032
3302
  return this.muted;
3033
3303
  }
3034
3304
  /**
3305
+ * The `User-Agent` this client put on its first request, or `null`.
3306
+ *
3307
+ * The ONLY identity that crosses from a consumer process into this session.
3308
+ * The restreamer prints it beside the pre-roll window it serves, which is how
3309
+ * a burst the broker measured can be tied to the file the consumer wrote —
3310
+ * `sessionId` is minted here and appears in no consumer's logs.
3311
+ */
3312
+ getUserAgent() {
3313
+ return this.userAgent;
3314
+ }
3315
+ /**
3035
3316
  * True only for a session that dialed the DETECTION path
3036
3317
  * (`DETECTION_MUTED_PATH_SUFFIX`). Read by
3037
3318
  * `RtspRestreamer.servePendingSessionsRtp` to exempt this session from the
@@ -3042,6 +3323,28 @@ var RtspSession = class {
3042
3323
  return this.intent === "detection";
3043
3324
  }
3044
3325
  /**
3326
+ * Does this session get the PRE-ROLL window instead of the current GOP?
3327
+ *
3328
+ * Both declared intents do, for the same reason: they attach after the edge
3329
+ * they need to see. Separate from {@link isDetectionIntent} because that one
3330
+ * also lifts the live-edge withhold, which is a MUTED-consumer rule and must
3331
+ * not be lifted for anyone else.
3332
+ */
3333
+ wantsPreRoll() {
3334
+ return this.preRollIntent() !== null;
3335
+ }
3336
+ /**
3337
+ * WHICH pre-roll window this session gets, or `null` when it declared no
3338
+ * intent and takes the ordinary current-GOP join burst.
3339
+ *
3340
+ * The two windows are two numbers (D193), so the restreamer needs the intent
3341
+ * and not just the bit: a recorder handed detection's window would carry the
3342
+ * pre-roll of a feature it is not, and on this fleet detection's is 0.
3343
+ */
3344
+ preRollIntent() {
3345
+ return this.intent === "detection" || this.intent === "recording" ? this.intent : null;
3346
+ }
3347
+ /**
3045
3348
  * Diagnostic snapshot consumed by `StreamBroker.listClients` (Phase 10).
3046
3349
  * Gives operators the information needed to identify a specific RTSP
3047
3350
  * session: where it's coming from, whether it's active, when it
@@ -3380,7 +3683,7 @@ var RtspListenServer = class RtspListenServer {
3380
3683
  async dispatch(socket, requestBuffer) {
3381
3684
  const uriMatch = (requestBuffer.split("\r\n")[0] ?? "").match(/\s(rtsp:\/\/[^\s]+)\s/);
3382
3685
  const pathMatch = (uriMatch ? uriMatch[1] : "").match(/rtsp:\/\/[^/]+(\/.*?)(?:\s|$)/);
3383
- const { lookupPath, muted: isMuted, intent } = require_remote_restream.parseRestreamPath(pathMatch ? pathMatch[1].replace(/^\//, "").replace(/\/trackID=\d+$/, "") : "");
3686
+ const { lookupPath, muted: isMuted, intent } = require_restream_intent.parseRestreamPath(pathMatch ? pathMatch[1].replace(/^\//, "").replace(/\/trackID=\d+$/, "") : "");
3384
3687
  const restreamer = this.restreamers.get(lookupPath);
3385
3688
  if (!restreamer) {
3386
3689
  socket.write(`RTSP/1.0 404 Not Found\r\nCSeq: 1\r\n\r\n`);
@@ -11031,6 +11334,8 @@ var StreamBroker = class StreamBroker {
11031
11334
  * isn't useful. 500 ms is wider than the worst-case fan-out delay.
11032
11335
  */
11033
11336
  lastRefreshRequestAt = 0;
11337
+ /** When {@link kickReconnect} last acted, for its own throttle. */
11338
+ lastKickAt = 0;
11034
11339
  /** Detected or configured codec — persists across reconnects */
11035
11340
  detectedCodec;
11036
11341
  /**
@@ -11168,6 +11473,58 @@ var StreamBroker = class StreamBroker {
11168
11473
  */
11169
11474
  static FIRST_VIDEO_TIMEOUT_MS = 8e3;
11170
11475
  /**
11476
+ * The first-video deadline for a dial that FOLLOWED a lazy materialize.
11477
+ *
11478
+ * 8 s was sized against Chrome's ~15 s ICE-disconnect: detect a stuck stream
11479
+ * before the viewer's browser gives up. That reasoning holds for a source
11480
+ * that is already open and has stalled. It is exactly wrong for a COLD open
11481
+ * behind a lazy publisher.
11482
+ *
11483
+ * Measured on the wyze bridge (devices 3838/3836, 2026-08-20): a successful
11484
+ * open costs **12.4 s** — `WyzeRfc4571Server` waits for the first keyframe
11485
+ * with a 10 s ceiling, on top of ~2 s of DTLS handshake. At 8 s the watchdog
11486
+ * tore down a connection that was two seconds from delivering, and the cost
11487
+ * of that teardown is not "try again quickly": the bridge closes its P2P
11488
+ * server 30 s after the last client, so the next dial repays the whole cold
11489
+ * open. Chrome had given up either way; the only thing the short deadline
11490
+ * changed was whether the NEXT viewer paid for it again too.
11491
+ *
11492
+ * 20 s covers the measured open with margin and still fires well inside the
11493
+ * activity watchdog's own recovery path for a connection that is genuinely
11494
+ * dead.
11495
+ */
11496
+ static LAZY_MATERIALIZE_FIRST_VIDEO_TIMEOUT_MS = 2e4;
11497
+ /**
11498
+ * How long after a lazy materialize was REQUESTED a dial still counts as
11499
+ * "this one is the cold open we asked for". Past it the source is an
11500
+ * ordinary open and takes the ordinary deadline.
11501
+ */
11502
+ static MATERIALIZE_RECENT_MS = 3e4;
11503
+ /**
11504
+ * Poll cadence while a lazy publisher is materializing its upstream.
11505
+ *
11506
+ * The exponential backoff is the right shape for a source that is FAILING
11507
+ * and the wrong one for a source that is being built: the publisher answers
11508
+ * in ~12 s, and 1-2-4-8-16 s means the broker learns about it at 16 — up to
11509
+ * 4 s of pure quantisation on every wyze cold open. Waiting at a flat 1 s
11510
+ * costs one cheap re-resolve per second against a bounded deadline.
11511
+ */
11512
+ static MATERIALIZE_POLL_MS = 1e3;
11513
+ /**
11514
+ * Bound on the flat-poll wait. Past it the materialize is not slow, it has
11515
+ * failed, and the ordinary exponential backoff is the right answer again.
11516
+ * Sized at the measured open (12.4 s) plus room for one retry inside the
11517
+ * publisher.
11518
+ */
11519
+ static MATERIALIZE_WAIT_MS = 2e4;
11520
+ /**
11521
+ * When the current lazy materialize was first requested, or `0` when none is
11522
+ * outstanding. Drives BOTH the flat poll above and the extended first-video
11523
+ * deadline — one fact, read in two places, so a materialize cannot be "in
11524
+ * progress" for the dial loop and "not happening" for the watchdog.
11525
+ */
11526
+ materializeRequestedAt = 0;
11527
+ /**
11171
11528
  * Rolling source-RTP activity watchdog. Distinct from the first-video
11172
11529
  * watchdog: this one ARMS at DIAL time (so a connection that opens the
11173
11530
  * loopback TCP but never delivers media is still detected) and RESETS on
@@ -11339,7 +11696,21 @@ var StreamBroker = class StreamBroker {
11339
11696
  * then behaves exactly as it did before pre-roll existed. Resolved per stream
11340
11697
  * by `resolvePreRollMs` and pushed by the manager.
11341
11698
  */
11342
- preRollMs = 0;
11699
+ detectionPreRollMs = 0;
11700
+ /**
11701
+ * Media time (ms) of history a RECORDING dial (`/recording`) is handed — the
11702
+ * `events`-band recorder's ffmpeg, which is spawned AT the trigger and whose
11703
+ * first frame is otherwise at best the live edge.
11704
+ *
11705
+ * A SECOND number, not a wider first one (D193). The two windows are set by
11706
+ * the operator for different reasons and cost different things: detection's
11707
+ * is a backlog a decoder must drain, recording's is bytes copied to disk.
11708
+ * Raising one to serve the other would change every detection dial on the
11709
+ * fleet to buy a recorder ten seconds — the coupling the 2026-08-20 census
11710
+ * named before anything was turned on. Resolved by `resolveRecordingPreRollMs`
11711
+ * and pushed by the manager.
11712
+ */
11713
+ recordingPreRollMs = 0;
11343
11714
  /**
11344
11715
  * Effective join-burst bound for this stream's RTSP restream — the cluster
11345
11716
  * default, or this camera stream's override. Applies to the RESTREAM
@@ -11353,20 +11724,9 @@ var StreamBroker = class StreamBroker {
11353
11724
  * long-GOP high-bitrate stream) drops the bootstrap rather than balloon
11354
11725
  * memory — late joiners fall back to waiting for the next keyframe. */
11355
11726
  static RTP_RING_MAX_BYTES = 24 * 1024 * 1024;
11356
- /**
11357
- * Byte budget for the EXTRA history pre-roll retains, over and above the
11358
- * current GOP.
11359
- *
11360
- * Its own budget rather than a share of {@link RTP_RING_MAX_BYTES} because
11361
- * the two failures are different: reaching the ring cap calls
11362
- * `resetRtpPreBuffer`, which drops the join bootstrap of EVERY consumer —
11363
- * WebRTC viewers included — for as long as the condition holds. A pre-roll
11364
- * that pushed a 4K stream into that state would be a knob for one camera's
11365
- * detection quietly degrading everybody else's picture. Over this budget the
11366
- * OLDEST retained GOP is dropped whole (never a tail cut), so the window
11367
- * stays decodable and simply carries less history than asked for.
11368
- */
11369
- static PRE_ROLL_MAX_BYTES = 8 * 1024 * 1024;
11727
+ /** Last budget printed by {@link reportPreRollClamp}, so the line fires on a
11728
+ * TRANSITION rather than on every keyframe. */
11729
+ preRollClampLogged = false;
11370
11730
  /** Stream stats tracking */
11371
11731
  totalBytes = 0;
11372
11732
  bytesInWindow = 0;
@@ -11406,10 +11766,13 @@ var StreamBroker = class StreamBroker {
11406
11766
  if (logger) this.rtspRestreamer.setLogger(logger.child("rtsp"));
11407
11767
  this.rtspRestreamer.setRtpPreBufferSupplier(() => this.getRtpPreBuffer());
11408
11768
  this.rtspRestreamer.setPrimeBoundSupplier(() => this.joinBurstBound);
11409
- this.rtspRestreamer.setPreRollSupplier(() => ({
11410
- packets: this.getRtpPreRollBuffer(),
11411
- preRollMs: this.preRollMs
11412
- }));
11769
+ this.rtspRestreamer.setPreRollSupplier((intent) => {
11770
+ const windowMs = intent === "recording" ? this.recordingPreRollMs : this.detectionPreRollMs;
11771
+ return {
11772
+ packets: () => this.getRtpPreRollBuffer(windowMs),
11773
+ preRollMs: windowMs
11774
+ };
11775
+ });
11413
11776
  this.preBuffer = new EncodedRingBuffer(StreamBroker.DEFAULT_PRE_BUFFER_SEC);
11414
11777
  this.decoderSeedBuffer = new EncodedRingBuffer(StreamBroker.DECODER_SEED_BUFFER_SEC);
11415
11778
  this.audioChunkPlane = new AudioChunkPlane(logger?.child("audio-chunk-plane"));
@@ -11557,6 +11920,9 @@ var StreamBroker = class StreamBroker {
11557
11920
  if (this.manualStop || this.stopping || !this.source) return;
11558
11921
  if (!this.hasDemand()) return;
11559
11922
  if (!this.reconnectTimer) return;
11923
+ const nowMs = Date.now();
11924
+ if (nowMs - this.lastKickAt < INITIAL_RECONNECT_DELAY_MS) return;
11925
+ this.lastKickAt = nowMs;
11560
11926
  clearTimeout(this.reconnectTimer);
11561
11927
  this.reconnectTimer = void 0;
11562
11928
  this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
@@ -12421,16 +12787,61 @@ var StreamBroker = class StreamBroker {
12421
12787
  * freshly-armed decoder, and a 6 s retention cannot serve a 6 s pre-roll plus
12422
12788
  * the GOP that carries it.
12423
12789
  */
12424
- setPreRollMs(ms) {
12790
+ setDetectionPreRollMs(ms) {
12425
12791
  const next = Number.isFinite(ms) && ms > 0 ? ms : 0;
12426
- if (next === this.preRollMs) return;
12427
- this.preRollMs = next;
12792
+ if (next === this.detectionPreRollMs) return;
12793
+ this.detectionPreRollMs = next;
12428
12794
  const seedSec = Math.max(StreamBroker.DECODER_SEED_BUFFER_SEC, Math.ceil(next / 1e3) + 2);
12429
12795
  this.decoderSeedBuffer.setDuration(seedSec);
12430
12796
  }
12431
- /** The pre-roll in force for this stream (ms). `0` = off. */
12432
- getPreRollMs() {
12433
- return this.preRollMs;
12797
+ /**
12798
+ * Configure how much history a RECORDING dial is handed.
12799
+ *
12800
+ * Touches the RETENTION (through {@link getPreRollRetentionMs}) and nothing
12801
+ * else: no decoder seed, no change to what any other consumer is served.
12802
+ */
12803
+ setRecordingPreRollMs(ms) {
12804
+ const next = Number.isFinite(ms) && ms > 0 ? ms : 0;
12805
+ this.recordingPreRollMs = next;
12806
+ }
12807
+ /** The DETECTION pre-roll in force for this stream (ms). `0` = off. */
12808
+ getDetectionPreRollMs() {
12809
+ return this.detectionPreRollMs;
12810
+ }
12811
+ /** The RECORDING pre-roll in force for this stream (ms). `0` = off. */
12812
+ getRecordingPreRollMs() {
12813
+ return this.recordingPreRollMs;
12814
+ }
12815
+ /**
12816
+ * How far back the ring is kept: the WIDER of the two windows.
12817
+ *
12818
+ * One retention serves both because the bytes are the same bytes — the ring
12819
+ * is cut per intent at read time ({@link getRtpPreRollBuffer}), so a 10 s
12820
+ * recording window does not hand a detection dial 10 s of backlog. Retaining
12821
+ * per intent would be two rings of the same packets.
12822
+ */
12823
+ getPreRollRetentionMs() {
12824
+ return Math.max(this.detectionPreRollMs, this.recordingPreRollMs);
12825
+ }
12826
+ /**
12827
+ * Byte budget for the EXTRA history pre-roll retains, over and above the
12828
+ * current GOP — derived from the window in force and this stream's OBSERVED
12829
+ * bitrate.
12830
+ *
12831
+ * Its own budget rather than a share of {@link RTP_RING_MAX_BYTES} because
12832
+ * the two failures are different: reaching the ring cap calls
12833
+ * `resetRtpPreBuffer`, which drops the join bootstrap of EVERY consumer —
12834
+ * WebRTC viewers included — for as long as the condition holds. A pre-roll
12835
+ * that pushed a 4K stream into that state would be a knob for one camera's
12836
+ * detection quietly degrading everybody else's picture. Over this budget the
12837
+ * OLDEST retained GOP is dropped whole (never a tail cut), so the window
12838
+ * stays decodable and simply carries less history than asked for.
12839
+ *
12840
+ * It replaced a FLAT 8 MiB constant, which was a promise this feature could
12841
+ * not keep in either direction — see {@link preRollBudgetBytes}.
12842
+ */
12843
+ getPreRollBudget() {
12844
+ return preRollBudgetBytes(this.getPreRollRetentionMs(), this.bitrateKbps);
12434
12845
  }
12435
12846
  /**
12436
12847
  * The seed replayed into a freshly-armed PUSH-mode decoder session.
@@ -12444,7 +12855,7 @@ var StreamBroker = class StreamBroker {
12444
12855
  * both, so a fix to the boundary cannot land on only one plane.
12445
12856
  */
12446
12857
  getDecoderSeedPackets() {
12447
- if (this.preRollMs <= 0) return this.decoderSeedBuffer.getPackets();
12858
+ if (this.detectionPreRollMs <= 0) return this.decoderSeedBuffer.getPackets();
12448
12859
  const all = this.decoderSeedBuffer.getAllPackets();
12449
12860
  if (all.length === 0) return [];
12450
12861
  const newestPts = all[all.length - 1].pts;
@@ -12456,7 +12867,7 @@ var StreamBroker = class StreamBroker {
12456
12867
  ageMs: newestPts - packet.pts
12457
12868
  });
12458
12869
  }
12459
- const selection = selectPreRollStart(marks, this.preRollMs);
12870
+ const selection = selectPreRollStart(marks, this.detectionPreRollMs);
12460
12871
  return selection.startIndex < 0 ? [] : all.slice(selection.startIndex);
12461
12872
  }
12462
12873
  /** Retained keyframe AUs as ages in ms of media time behind `newestTs`. */
@@ -12478,14 +12889,18 @@ var StreamBroker = class StreamBroker {
12478
12889
  trimRtpRingToPreRollWindow() {
12479
12890
  if (this.rtpRingKeyframeMarks.length === 0 || this.rtpRing.length === 0) return;
12480
12891
  const newestTs = this.rtpRing[this.rtpRing.length - 1].readUInt32BE(4);
12481
- let cut = selectPreRollStart(this.rtpPreRollMarks(newestTs), this.preRollMs).startIndex;
12892
+ const marks = this.rtpPreRollMarks(newestTs);
12893
+ const retentionMs = this.getPreRollRetentionMs();
12894
+ let cut = selectPreRollStart(marks, retentionMs).startIndex;
12482
12895
  if (cut < 0) return;
12483
- if (this.preRollMs > 0) {
12896
+ if (retentionMs > 0) {
12897
+ const budget = this.getPreRollBudget();
12898
+ this.reportPreRollClamp(budget, retentionMs);
12484
12899
  const historyEnd = this.rtpRingKeyframeMarks[this.rtpRingKeyframeMarks.length - 1].index;
12485
12900
  let retained = 0;
12486
12901
  for (let i = cut; i < historyEnd; i++) retained += this.rtpRing[i].length;
12487
12902
  let markIdx = this.rtpRingKeyframeMarks.findIndex((m) => m.index === cut);
12488
- while (retained > StreamBroker.PRE_ROLL_MAX_BYTES && markIdx >= 0 && markIdx < this.rtpRingKeyframeMarks.length - 1) {
12903
+ while (retained > budget.bytes && markIdx >= 0 && markIdx < this.rtpRingKeyframeMarks.length - 1) {
12489
12904
  const nextCut = this.rtpRingKeyframeMarks[markIdx + 1].index;
12490
12905
  for (let i = cut; i < nextCut; i++) retained -= this.rtpRing[i].length;
12491
12906
  cut = nextCut;
@@ -12522,13 +12937,37 @@ var StreamBroker = class StreamBroker {
12522
12937
  * when motion fired retained nothing, and the honest outcome there is the
12523
12938
  * Stage 0 burst rather than a pretend one.
12524
12939
  */
12525
- getRtpPreRollBuffer() {
12940
+ getRtpPreRollBuffer(windowMs) {
12526
12941
  if (!this.rtpRingHasKeyframe) return [];
12527
- if (this.preRollMs <= 0 || this.rtpRing.length === 0) return this.getRtpPreBuffer();
12942
+ if (windowMs <= 0 || this.rtpRing.length === 0) return this.getRtpPreBuffer();
12528
12943
  const newestTs = this.rtpRing[this.rtpRing.length - 1].readUInt32BE(4);
12529
- const selection = selectPreRollStart(this.rtpPreRollMarks(newestTs), this.preRollMs);
12944
+ const selection = selectPreRollStart(this.rtpPreRollMarks(newestTs), windowMs);
12530
12945
  return selection.startIndex < 0 ? [] : this.rtpRing.slice(selection.startIndex);
12531
12946
  }
12947
+ /**
12948
+ * Say it, ONCE per transition, when the absolute ceiling truncates the window
12949
+ * the operator configured.
12950
+ *
12951
+ * A clamp nobody prints is indistinguishable from a camera that was simply
12952
+ * not warm long enough, and the two call for opposite actions (buy the RAM
12953
+ * back vs wait). The flat 8 MiB cap this replaced clamped SILENTLY, which is
12954
+ * how "10 s" could mean 6 s on a 4K stream for as long as it did.
12955
+ */
12956
+ reportPreRollClamp(budget, retentionMs) {
12957
+ if (budget.clamped === this.preRollClampLogged) return;
12958
+ this.preRollClampLogged = budget.clamped;
12959
+ if (!budget.clamped) return;
12960
+ this.logger?.warn("pre-roll budget clamped — the window will carry less than configured", {
12961
+ tags: { deviceId: this.numericDeviceId },
12962
+ meta: {
12963
+ retentionMs,
12964
+ detectionPreRollMs: this.detectionPreRollMs,
12965
+ recordingPreRollMs: this.recordingPreRollMs,
12966
+ bitrateKbps: this.bitrateKbps,
12967
+ budgetBytes: budget.bytes
12968
+ }
12969
+ });
12970
+ }
12532
12971
  getSourceType() {
12533
12972
  return this.source?.type ?? null;
12534
12973
  }
@@ -12635,7 +13074,9 @@ var StreamBroker = class StreamBroker {
12635
13074
  getFirstVideoWatchdogStatus() {
12636
13075
  return {
12637
13076
  armed: this.firstVideoWatchdog !== void 0,
12638
- videoRtpSeen: this.videoRtpSeen
13077
+ videoRtpSeen: this.videoRtpSeen,
13078
+ timeoutMs: this.firstVideoTimeoutMs(),
13079
+ materializeRequestedAt: this.materializeRequestedAt
12639
13080
  };
12640
13081
  }
12641
13082
  /**
@@ -13098,6 +13539,7 @@ var StreamBroker = class StreamBroker {
13098
13539
  this.nextDialAllowedAt = 0;
13099
13540
  this.stopErrorPlaceholder();
13100
13541
  this.cancelFirstVideoWatchdog();
13542
+ this.materializeRequestedAt = 0;
13101
13543
  this.kickRtpActivityWatchdog();
13102
13544
  this.videoRtpSeen++;
13103
13545
  if (this.videoRtpSeen === 1 || this.videoRtpSeen === 50) this.logger?.info("video: rtp packet received", { meta: {
@@ -13424,10 +13866,12 @@ var StreamBroker = class StreamBroker {
13424
13866
  armFirstVideoWatchdog() {
13425
13867
  if (this.firstVideoWatchdog) return;
13426
13868
  if (this.manualStop || this.stopping) return;
13869
+ const timeoutMs = this.firstVideoTimeoutMs();
13427
13870
  this.firstVideoWatchdog = setTimeout(() => {
13428
13871
  this.firstVideoWatchdog = void 0;
13429
13872
  if (this.manualStop || this.stopping) return;
13430
13873
  if (this.videoRtpSeen > 0) {
13874
+ this.materializeRequestedAt = 0;
13431
13875
  this.logger?.info("first-video watchdog: video flowing — standing down", {
13432
13876
  tags: { deviceId: this.numericDeviceId },
13433
13877
  meta: {
@@ -13440,7 +13884,7 @@ var StreamBroker = class StreamBroker {
13440
13884
  this.logger?.warn("first-video watchdog: no video RTP after source connect — forcing reconnect", {
13441
13885
  tags: { deviceId: this.numericDeviceId },
13442
13886
  meta: {
13443
- timeoutMs: StreamBroker.FIRST_VIDEO_TIMEOUT_MS,
13887
+ timeoutMs,
13444
13888
  ...this.sourceMetaForLog()
13445
13889
  }
13446
13890
  });
@@ -13451,7 +13895,19 @@ var StreamBroker = class StreamBroker {
13451
13895
  this.lastRefreshRequestAt = 0;
13452
13896
  if (this.source?.type === "rfc4571") this.notifySourceRefresh();
13453
13897
  this.scheduleReconnect();
13454
- }, StreamBroker.FIRST_VIDEO_TIMEOUT_MS);
13898
+ }, timeoutMs);
13899
+ }
13900
+ /**
13901
+ * The first-video deadline for the dial being armed.
13902
+ *
13903
+ * Extended for a dial that FOLLOWED a lazy materialize we asked for — see
13904
+ * {@link LAZY_MATERIALIZE_FIRST_VIDEO_TIMEOUT_MS}. It reads the SAME fact the
13905
+ * dial loop uses (`materializeRequestedAt`), so a cold open cannot be "in
13906
+ * progress" for one and "not happening" for the other.
13907
+ */
13908
+ firstVideoTimeoutMs() {
13909
+ if (this.materializeRequestedAt === 0) return StreamBroker.FIRST_VIDEO_TIMEOUT_MS;
13910
+ return Date.now() - this.materializeRequestedAt < StreamBroker.MATERIALIZE_RECENT_MS ? StreamBroker.LAZY_MATERIALIZE_FIRST_VIDEO_TIMEOUT_MS : StreamBroker.FIRST_VIDEO_TIMEOUT_MS;
13455
13911
  }
13456
13912
  /** Cancel the first-video watchdog. Idempotent. */
13457
13913
  cancelFirstVideoWatchdog() {
@@ -13656,12 +14112,29 @@ var StreamBroker = class StreamBroker {
13656
14112
  startRfc4571Reader(source) {
13657
14113
  this.logger?.info("starting RFC 4571 reader");
13658
14114
  if (typeof source.url === "string" && source.url.startsWith("lazy:rfc4571:")) {
13659
- this.logger?.info("rfc4571: lazy URL — requesting publisher to materialize upstream", { meta: { url: source.url } });
14115
+ const nowMs = Date.now();
14116
+ if (this.materializeRequestedAt === 0) this.materializeRequestedAt = nowMs;
14117
+ const waitedMs = nowMs - this.materializeRequestedAt;
14118
+ const withinDeadline = waitedMs < StreamBroker.MATERIALIZE_WAIT_MS;
14119
+ if (withinDeadline) this.reconnectDelayMs = StreamBroker.MATERIALIZE_POLL_MS;
14120
+ this.logger?.info("rfc4571: lazy URL — requesting publisher to materialize upstream", {
14121
+ tags: { deviceId: this.numericDeviceId },
14122
+ meta: {
14123
+ url: source.url,
14124
+ waitedMs,
14125
+ schedule: withinDeadline ? "materialize-poll" : "backoff",
14126
+ nextDelayMs: this.reconnectDelayMs
14127
+ }
14128
+ });
13660
14129
  this._status = "error";
13661
14130
  this.notifySourceRefresh();
13662
14131
  this.scheduleReconnect();
13663
14132
  return;
13664
14133
  }
14134
+ if (this.materializeRequestedAt > 0) this.logger?.info("rfc4571: publisher materialized — dialling the real upstream", {
14135
+ tags: { deviceId: this.numericDeviceId },
14136
+ meta: { materializeMs: Date.now() - this.materializeRequestedAt }
14137
+ });
13665
14138
  const sdp = typeof source.metadata?.["sdp"] === "string" ? source.metadata["sdp"] : null;
13666
14139
  if (!sdp) {
13667
14140
  this.logger?.warn("rfc4571: source missing metadata.sdp — requesting publisher refresh");
@@ -14663,6 +15136,13 @@ var StreamBrokerManager = class StreamBrokerManager {
14663
15136
  * bytes on the ingest node, so it is turned on per camera that needs it.
14664
15137
  */
14665
15138
  defaultPreRollMs = 0;
15139
+ /**
15140
+ * Cluster-wide default RECORDING pre-roll (ms). `undefined` until the addon
15141
+ * pushes its config — which is a different fact from `0` and must stay so:
15142
+ * `0` is an operator turning the feature OFF, `undefined` is a boot that has
15143
+ * not read the setting yet and takes `DEFAULT_RECORDING_PRE_ROLL_MS`.
15144
+ */
15145
+ defaultRecordingPreRollMs = void 0;
14666
15146
  webrtcServer = null;
14667
15147
  /**
14668
15148
  * brokerId → the remote-encoded adapter backing a non-owned camera's WebRTC
@@ -15307,13 +15787,32 @@ var StreamBrokerManager = class StreamBrokerManager {
15307
15787
  * decision); an explicit per-stream override still reaches it.
15308
15788
  */
15309
15789
  applyPreRoll(broker, deviceId, camStreamId) {
15310
- broker.setPreRollMs(resolvePreRollMs({
15790
+ const terminalRelay = this.cameraStreams.get(deviceId)?.get(camStreamId)?.kind === "pull-http";
15791
+ broker.setDetectionPreRollMs(resolvePreRollMs({
15311
15792
  camStreamId,
15312
15793
  clusterMs: this.defaultPreRollMs,
15313
15794
  overrides: this.deviceOverrides.get(deviceId)?.preRollMs,
15314
- terminalRelay: this.cameraStreams.get(deviceId)?.get(camStreamId)?.kind === "pull-http"
15795
+ terminalRelay
15796
+ }));
15797
+ broker.setRecordingPreRollMs(resolveRecordingPreRollMs({
15798
+ camStreamId,
15799
+ clusterMs: this.defaultRecordingPreRollMs,
15800
+ terminalRelay,
15801
+ batteryOperated: this.deviceHasFeature(deviceId, require_dist.DeviceFeature.BatteryOperated)
15315
15802
  }));
15316
15803
  }
15804
+ /**
15805
+ * Set the cluster-wide default RECORDING pre-roll and push it into every live
15806
+ * broker — same reasoning as detection's: a broker outlives its setting.
15807
+ */
15808
+ setDefaultRecordingPreRollMs(ms) {
15809
+ this.defaultRecordingPreRollMs = ms;
15810
+ for (const [brokerId, broker] of this.brokers) {
15811
+ const parsed = parseBrokerId(brokerId);
15812
+ if (!parsed) continue;
15813
+ this.applyPreRoll(broker, parsed.deviceId, parsed.camStreamId);
15814
+ }
15815
+ }
15317
15816
  setEventBus(bus) {
15318
15817
  this.eventBus = bus;
15319
15818
  this.startStreamHealthWatchdog();
@@ -15901,7 +16400,7 @@ var StreamBrokerManager = class StreamBrokerManager {
15901
16400
  return { success: true };
15902
16401
  }
15903
16402
  const existing = this.upsertSourceCameraStream(deviceId, input);
15904
- const urlRefreshed = existing !== void 0 && input.url !== void 0 && existing.url !== input.url;
16403
+ existing !== void 0 && input.url !== void 0 && (existing.url, input.url);
15905
16404
  const wasAlreadyAssigned = (PUSH_KINDS.has(kind) ? findProfileForStream(this.assignments.get(deviceId)?.map ?? {}, camStreamId) : null) !== null && existing !== void 0;
15906
16405
  await this.reconcileAutoAssignment(deviceId, "publish", existing === void 0);
15907
16406
  if (wasAlreadyAssigned) {
@@ -15909,7 +16408,7 @@ var StreamBrokerManager = class StreamBrokerManager {
15909
16408
  if (finalProfile !== null) this.emitCamStreamDemand(deviceId, camStreamId, finalProfile);
15910
16409
  }
15911
16410
  await this.ensureBroker(deviceId, camStreamId);
15912
- if (urlRefreshed) this.brokers.get(brokerIdFor(deviceId, camStreamId))?.kickReconnect();
16411
+ this.brokers.get(brokerIdFor(deviceId, camStreamId))?.kickReconnect();
15913
16412
  const justRegisteredBrokerId = brokerIdFor(deviceId, camStreamId);
15914
16413
  const pending = this.pendingDerivedStreams.get(justRegisteredBrokerId);
15915
16414
  if (pending) {
@@ -17770,7 +18269,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17770
18269
  else delete pr[camStreamId];
17771
18270
  nextOverride.preRollMs = pr;
17772
18271
  const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
17773
- if (broker) broker.setPreRollMs(resolvePreRollMs({
18272
+ if (broker) broker.setDetectionPreRollMs(resolvePreRollMs({
17774
18273
  camStreamId,
17775
18274
  clusterMs: this.defaultPreRollMs,
17776
18275
  overrides: pr,
@@ -18423,7 +18922,11 @@ var StreamBrokerManager = class StreamBrokerManager {
18423
18922
  const cam = this.cameraStreams.get(deviceId)?.get(camStreamId);
18424
18923
  if (!cam) return;
18425
18924
  const brokerId = brokerIdFor(deviceId, camStreamId);
18426
- if (this.brokers.has(brokerId)) return;
18925
+ const live = this.brokers.get(brokerId);
18926
+ if (live) {
18927
+ this.applyPreRoll(live, deviceId, camStreamId);
18928
+ return;
18929
+ }
18427
18930
  const existingEnsure = this.brokerEnsures.get(brokerId);
18428
18931
  if (existingEnsure) {
18429
18932
  await existingEnsure;
@@ -35301,6 +35804,7 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
35301
35804
  catalogReconcileIntervalSec: 30,
35302
35805
  joinBurstMaxSpanMs: DEFAULT_PRIME_BURST_BOUND.maxSpanMs,
35303
35806
  preRollMs: 0,
35807
+ recordingPreRollMs: DEFAULT_RECORDING_PRE_ROLL_MS,
35304
35808
  binaryPath: "",
35305
35809
  hwaccel: "copy"
35306
35810
  });
@@ -35360,6 +35864,7 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
35360
35864
  this.brokerManager.setDefaultPreBufferSec(this.config.defaultPreBufferSec);
35361
35865
  this.brokerManager.setDefaultJoinBurstMaxSpanMs(this.config.joinBurstMaxSpanMs);
35362
35866
  this.brokerManager.setDefaultPreRollMs(this.config.preRollMs);
35867
+ this.brokerManager.setDefaultRecordingPreRollMs(this.config.recordingPreRollMs);
35363
35868
  this.brokerManager.setEventBus(this.ctx.eventBus);
35364
35869
  const localNode = this.ctx.kernel.localNodeId;
35365
35870
  const localNodeId = localNode ? localNode.includes("/") ? localNode.split("/")[0] : localNode : void 0;
@@ -35942,6 +36447,17 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
35942
36447
  default: 0,
35943
36448
  unit: "ms"
35944
36449
  },
36450
+ {
36451
+ type: "number",
36452
+ key: "recordingPreRollMs",
36453
+ label: "Recording pre-roll",
36454
+ 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.",
36455
+ min: 0,
36456
+ max: RECORDING_PRE_ROLL_MAX_MS,
36457
+ step: 500,
36458
+ default: DEFAULT_RECORDING_PRE_ROLL_MS,
36459
+ unit: "ms"
36460
+ },
35945
36461
  {
35946
36462
  type: "number",
35947
36463
  key: "catalogReconcileIntervalSec",