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}