Skip to main content

seyd_qos/
lib.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! QoS profiles — the latency/quality trade-off as a named choice.
4//!
5//! The link constrains *total bytes on the wire*, so a profile is one budget
6//! split three ways: pixels, redundancy, headroom. The boundary from SPEC.md:
7//! Seyd owns the transport-observable targets (bitrate ceiling, latency budget,
8//! max GOP) and its own policy (FEC rates, backlog drop threshold, the pilot's
9//! close-out deadlines). The publisher owns resolution, preset, VBV and the
10//! actual GOP. **There is no resolution here, and there must never be.**
11//!
12//! The closed-loop ABR controller that moves the bitrate *inside* the ceiling
13//! is `abr::AbrController`; the profile is its bound.
14//!
15//! Where a publisher offers several encodings of the same picture,
16//! `simulcast::LayerSelector` picks which one those bits buy (ADR 0008). Note
17//! that it names layers and the bitrate each needs — never their resolution, so
18//! the boundary above still holds.
19
20pub mod abr;
21pub mod simulcast;
22
23use serde::{Deserialize, Serialize};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum OnLoss {
28    Continue,
29    FreezeUntilIdr,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33pub struct Profile {
34    /// The profile's name: `latency`, `balanced` or `quality`.
35    pub name: &'static str,
36    // ── targets the publisher must honour ───────────────────────────────
37    /// The ceiling on the publisher's video bitrate; the closed-loop controller
38    /// moves the actual request between 25 % of this and this.
39    pub max_bitrate_kbps: u32,
40    /// How much end-to-end delay the profile is prepared to spend, told to the
41    /// publisher so it can size its own buffers (VBV, lookahead).
42    pub latency_budget_ms: u32,
43    /// The longest the publisher may go without a full recovery point — an
44    /// IDR, or a completed intra-refresh sweep. A *ceiling*, and a safety net:
45    /// Seyd asks for recovery points when it needs them (`recovery-request`),
46    /// so the periodic one only has to catch a publisher that ignores it
47    /// (ADR 0009). It is long on purpose: a periodic IDR is several times a
48    /// delta frame, and measured on the demo camera it arrived ~22 ms late and
49    /// made every GOP's first two intervals 60 ms then 18 ms; at 10 s GOPs
50    /// arrival judder fell from 3.7 to 0.9 ms mean and the stream lost 43 % of
51    /// its bitrate, the IDRs' share. A joining pilot never waits for it: the
52    /// agent asks for an IDR on `hello`.
53    pub max_gop_ms: u32,
54    /// Ask the publisher to refresh the picture gradually — a strip of
55    /// intra-coded blocks per frame sweeping the picture within `max_gop_ms` —
56    /// rather than with periodic IDRs, where its encoder can (x264, NVENC,
57    /// Jetson; not the ONVIF cameras surveyed). Removes the keyframe burst
58    /// entirely; measured on the sim through a shaped link as g2g p95
59    /// 148 → 48 ms (docs/latency-sources.md §11).
60    pub prefer_intra_refresh: bool,
61    // ── Seyd's own transport policy ──────────────────────────────────────
62    /// Reed-Solomon parity added to a delta frame's block, as a percentage of
63    /// its data chunks, on a clean link; the controller raises it under loss.
64    pub fec_delta_pct: u32,
65    /// Parity for keyframes, which are larger and cost a GOP if torn.
66    pub fec_key_pct: u32,
67    /// Drop a delta frame if the send backlog exceeds this many frame-times.
68    pub backlog_drop_frames: u32,
69    /// How long the pilot waits for a delta frame's missing chunks before
70    /// closing it out.
71    pub pilot_deadline_delta_ms: u32,
72    /// The same wait for a keyframe.
73    pub pilot_deadline_key_ms: u32,
74    /// How long the pilot holds a decoded frame past its capture slot before
75    /// painting it (ADR 0005). This is the profile's judder-versus-latency
76    /// call: a keyframe is ~8x a delta frame and is produced in one frame slot,
77    /// so without a budget here the picture hitches once per GOP. It is a
78    /// bound, not an average — a frame never waits longer than this past its
79    /// own arrival — so it is the latency the profile is prepared to spend.
80    /// 0 disables pacing and restores decode-on-arrival.
81    pub pilot_presentation_delay_ms: u32,
82    /// How long the sender waits for a recovery point after asking for one
83    /// before it resumes sending delta frames regardless.
84    ///
85    /// A publisher that recovers with intra-refresh or an LTR reference never
86    /// produces a keyframe, so waiting for one latches the stream silent
87    /// forever. Roughly one refresh cycle: long enough that a real keyframe
88    /// arrives first when the publisher sends IDRs, short enough that a stall
89    /// is not visible as a freeze.
90    pub recovery_grace_ms: u32,
91    /// What the pilot does when a frame cannot be repaired: keep decoding
92    /// (`continue`, and ask for a recovery point) or freeze until the next IDR.
93    pub on_loss: OnLoss,
94}
95
96pub const LATENCY: Profile = Profile {
97    name: "latency",
98    max_bitrate_kbps: 1500,
99    latency_budget_ms: 100,
100    max_gop_ms: 10_000,
101    prefer_intra_refresh: true,
102    fec_delta_pct: 25,
103    fec_key_pct: 50,
104    backlog_drop_frames: 1,
105    pilot_deadline_delta_ms: 20,
106    pilot_deadline_key_ms: 40,
107    // Half the default: still covers the measured keyframe excess (15–80 ms),
108    // and this profile spends latency nowhere it does not have to.
109    pilot_presentation_delay_ms: 50,
110    recovery_grace_ms: 700,
111    on_loss: OnLoss::Continue,
112};
113
114pub const BALANCED: Profile = Profile {
115    name: "balanced",
116    max_bitrate_kbps: 3000,
117    latency_budget_ms: 100,
118    max_gop_ms: 10_000,
119    prefer_intra_refresh: true,
120    fec_delta_pct: 15,
121    fec_key_pct: 30,
122    backlog_drop_frames: 2,
123    pilot_deadline_delta_ms: 30,
124    pilot_deadline_key_ms: 60,
125    // Measured on the demo camera: judder p95 77 ms → 40 ms, and hitches over
126    // 120 ms fall from 61 to 7 per 40 s. See ADR 0005 for the full table.
127    pilot_presentation_delay_ms: 100,
128    recovery_grace_ms: 900,
129    on_loss: OnLoss::Continue,
130};
131
132pub const QUALITY: Profile = Profile {
133    name: "quality",
134    max_bitrate_kbps: 6000,
135    latency_budget_ms: 200,
136    // Shorter than the other profiles: this one freezes on loss until an IDR,
137    // so the safety net has to be closer for a publisher that ignores requests.
138    max_gop_ms: 4000,
139    prefer_intra_refresh: true,
140    fec_delta_pct: 8,
141    fec_key_pct: 15,
142    backlog_drop_frames: 3,
143    pilot_deadline_delta_ms: 50,
144    pilot_deadline_key_ms: 100,
145    // This profile already spends 200 ms of latency budget and a 2 s GOP, so
146    // the extra 50 ms buys the smoothest picture available: judder p95 15 ms.
147    pilot_presentation_delay_ms: 150,
148    recovery_grace_ms: 1400,
149    on_loss: OnLoss::FreezeUntilIdr,
150};
151
152pub const PROFILES: [&Profile; 3] = [&LATENCY, &BALANCED, &QUALITY];
153pub const DEFAULT: &Profile = &BALANCED;
154
155/// Look up a profile by name (case-insensitive). `None` for unknown names —
156/// callers decide whether to fall back to `DEFAULT` or reject.
157pub fn get(name: &str) -> Option<&'static Profile> {
158    let n = name.trim().to_ascii_lowercase();
159    PROFILES.iter().copied().find(|p| p.name == n)
160}
161
162impl Profile {
163    /// Backlog above which a delta frame is dropped before any of it is sent.
164    /// A byte budget rather than "queue non-empty": the transport's pending
165    /// queue also grows merely because the pacer is spacing packets out.
166    pub fn drop_threshold_bytes(&self, fps: u32) -> usize {
167        self.drop_threshold_bytes_at(self.max_bitrate_kbps, fps)
168    }
169
170    /// The same budget at a ceiling other than the profile's own — a
171    /// publisher that tops out lower has smaller frames, and a backlog of
172    /// "a few frames" is correspondingly fewer bytes.
173    pub fn drop_threshold_bytes_at(&self, ceiling_kbps: u32, fps: u32) -> usize {
174        let bytes_per_frame = ceiling_kbps as f64 * 1000.0 / 8.0 / fps.max(1) as f64;
175        (self.backlog_drop_frames as f64 * bytes_per_frame) as usize
176    }
177
178    /// The half of the profile the video publisher is responsible for, as the
179    /// `video-config` publisher-control message (docs/protocol/seydd.md).
180    pub fn publisher_config(&self, channel: u8, reason: &str) -> serde_json::Value {
181        serde_json::json!({
182            "type": "video-config",
183            "channel": channel,
184            "profile": self.name,
185            "maxBitrateKbps": self.max_bitrate_kbps,
186            "latencyBudgetMs": self.latency_budget_ms,
187            "maxGopMs": self.max_gop_ms,
188            "preferIntraRefresh": self.prefer_intra_refresh,
189            "suggestedFps": 0,
190            "reason": reason,
191        })
192    }
193
194    /// What the pilot needs: close-out deadlines, presentation delay and loss
195    /// policy (`welcome.qos` / `qos-ack.qos` in docs/protocol/control-stream.md).
196    pub fn pilot_config(&self) -> serde_json::Value {
197        serde_json::json!({
198            "profile": self.name,
199            "deadline_delta_ms": self.pilot_deadline_delta_ms,
200            "deadline_key_ms": self.pilot_deadline_key_ms,
201            "presentation_delay_ms": self.pilot_presentation_delay_ms,
202            "on_loss": self.on_loss,
203        })
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn lookup() {
213        assert_eq!(get("Latency").unwrap().name, "latency");
214        assert!(get("turbo").is_none());
215    }
216
217    #[test]
218    fn drop_threshold_matches_python() {
219        // balanced, 30 fps: 2 frames × 12 500 B
220        assert_eq!(BALANCED.drop_threshold_bytes(30), 25_000);
221        assert_eq!(LATENCY.drop_threshold_bytes(25), 7_500);
222    }
223
224    #[test]
225    fn keyframes_are_on_demand_with_a_long_safety_net() {
226        // ADR 0009: the periodic keyframe is a safety net for a publisher that
227        // ignores recovery requests, not the recovery mechanism, so it is long
228        // — and intra refresh is asked for wherever the encoder has it.
229        for p in PROFILES {
230            assert!(p.max_gop_ms >= 4000, "{}: {} ms", p.name, p.max_gop_ms);
231            assert!(p.prefer_intra_refresh);
232        }
233        // The profile that freezes on loss until an IDR keeps its net closer.
234        let (q, b) = (get("quality").unwrap(), get("balanced").unwrap());
235        assert!(
236            q.max_gop_ms < b.max_gop_ms,
237            "{} vs {}",
238            q.max_gop_ms,
239            b.max_gop_ms
240        );
241        let v = BALANCED.publisher_config(1, "profile");
242        assert_eq!(v["maxGopMs"], 10_000);
243        assert_eq!(v["preferIntraRefresh"], true);
244    }
245
246    #[test]
247    fn pilot_config_serialises_on_loss_kebab() {
248        let v = QUALITY.pilot_config();
249        assert_eq!(v["on_loss"], "freeze-until-idr");
250        assert_eq!(v["deadline_key_ms"], 100);
251    }
252
253    #[test]
254    fn presentation_delay_rises_with_the_profile_latency_budget() {
255        // The pacing budget (ADR 0005) is a latency spend, so it must be
256        // ordered the same way as every other latency knob in the profiles.
257        let v = BALANCED.pilot_config();
258        assert_eq!(v["presentation_delay_ms"], 100);
259        let delays: Vec<u32> = ["latency", "balanced", "quality"]
260            .iter()
261            .map(|n| get(n).unwrap().pilot_presentation_delay_ms)
262            .collect();
263        assert!(delays.windows(2).all(|w| w[0] < w[1]), "{delays:?}");
264        // It must stay inside the budget the publisher is asked to hit.
265        for p in PROFILES {
266            assert!(
267                p.pilot_presentation_delay_ms <= p.latency_budget_ms,
268                "{} spends {} ms pacing of a {} ms budget",
269                p.name,
270                p.pilot_presentation_delay_ms,
271                p.latency_budget_ms
272            );
273        }
274    }
275}