mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-27 04:52:25 +03:00
feat: media bitrate control, HDR (#346)
Fixes: #335 Still a work-in-progress. --------- Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Wanjohi <elviswanjohi47@gmail.com>
This commit is contained in:
co-authored by
DatCaptainHorse
Claude Opus 5
Wanjohi
parent
1c721962f4
commit
0811f57f1a
@@ -0,0 +1,327 @@
|
||||
//! How much a delay varies, measured the same way at both ends.
|
||||
//!
|
||||
//! # What this measures
|
||||
//!
|
||||
//! Every frame carries the timestamp the sender stamped on it at capture. The
|
||||
//! two clocks are unrelated, so the difference between that and the moment the
|
||||
//! frame arrives here is meaningless on its own -- it contains an unknown,
|
||||
//! roughly constant offset. Its *variation* is not meaningless, and variation is
|
||||
//! the only thing a playout buffer exists to absorb: a path that delivers every
|
||||
//! frame exactly 300 ms late needs no buffer at all, while one that alternates
|
||||
//! between 10 ms and 60 ms needs 50 ms whatever its average.
|
||||
//!
|
||||
//! So this tracks the smallest difference seen recently -- the best the path has
|
||||
//! managed, which stands in for the unknown offset -- and reports how far above
|
||||
//! it each frame lands.
|
||||
//!
|
||||
//! # What it deliberately does not do
|
||||
//!
|
||||
//! It does not distinguish the sender's contribution from the network's. The
|
||||
//! sender reports its own pipeline delay separately, and the two are compared
|
||||
//! rather than subtracted: a frame that was late because the encoder stalled
|
||||
//! should not raise a buffer, because buffering is latency spent hiding a fault
|
||||
//! that should be fixed instead.
|
||||
//!
|
||||
//! # Why this is shared
|
||||
//!
|
||||
//! Both ends measure a delay and the two are compared: the hub reports how much
|
||||
//! its own pipeline varied before a frame left, the client reports how much the
|
||||
//! total varied by the time it arrived. A comparison between two different
|
||||
//! measures would be meaningless, so there is one measure, defined once.
|
||||
//!
|
||||
//! It also has to be *variation* on both sides rather than absolute delay,
|
||||
//! because neither side can know the absolute. The hub's `timestamp_ms` counts
|
||||
//! from the encoder's own start, not from any epoch, so even on one machine the
|
||||
//! difference to wall clock contains an unknown constant -- and across two
|
||||
//! machines there is no shared clock at all.
|
||||
//!
|
||||
//! Nothing here adapts anything yet. It measures, so that the decision about
|
||||
//! what to adapt is made against a distribution rather than an intuition.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a best-case observation stays authoritative.
|
||||
///
|
||||
/// The minimum has to expire. Paths change -- a relay is dropped for a direct
|
||||
/// connection, a phone moves between cells -- and a minimum from the old path
|
||||
/// makes every frame on the new one look permanently late, which would pin a
|
||||
/// buffer at a size nothing needs. Long enough that an ordinary quiet spell does
|
||||
/// not reset the baseline, short enough that a genuine change is noticed.
|
||||
const BASELINE_WINDOW: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Ceiling on retained observations, so a stalled consumer cannot grow this.
|
||||
const MAX_SAMPLES: usize = 4096;
|
||||
|
||||
/// One second's worth of lateness, summarised.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct DelaySummary {
|
||||
/// Frames measured.
|
||||
pub frames: u32,
|
||||
/// Median lateness above the best the path has managed, in milliseconds.
|
||||
pub p50_ms: u16,
|
||||
/// The 95th percentile of the same.
|
||||
pub p95_ms: u16,
|
||||
/// The worst single frame.
|
||||
pub max_ms: u16,
|
||||
}
|
||||
|
||||
/// Tracks arrival lateness for one media stream.
|
||||
#[derive(Debug)]
|
||||
pub struct DelayTracker {
|
||||
/// Best-case delay observations, each with when it was taken, oldest first.
|
||||
///
|
||||
/// A deque rather than a single value because a minimum that can only fall
|
||||
/// never recovers from a path that improved, and one that is simply reset on
|
||||
/// a timer throws away a good baseline for no reason. Holding the recent
|
||||
/// candidates lets the oldest expire while a better one is still standing.
|
||||
baseline: VecDeque<(Instant, i32)>,
|
||||
/// Lateness of each frame this second, in milliseconds.
|
||||
samples: Vec<u16>,
|
||||
/// The first delay seen, as an anchor for every later one.
|
||||
///
|
||||
/// Delays are compared *relative to this*, in wrapping arithmetic. Both
|
||||
/// clocks are unrelated and the sender's stamp is a `u32` of milliseconds
|
||||
/// that wraps every 49 days, so an absolute subtraction is a number with no
|
||||
/// meaning and a wrap in the middle of a session makes it jump by 2^32 --
|
||||
/// which, taken as lateness, reads as every frame being weeks late until the
|
||||
/// baseline expires. Anchoring and then interpreting the difference as
|
||||
/// signed makes the wrap a non-event, because the quantity that matters is
|
||||
/// only ever a few hundred milliseconds wide.
|
||||
anchor: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for DelayTracker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DelayTracker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
baseline: VecDeque::new(),
|
||||
samples: Vec::new(),
|
||||
anchor: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one frame's arrival.
|
||||
///
|
||||
/// `stamp_ms` is the timestamp the frame carries; `observed_ms` is the local
|
||||
/// clock at the point being measured -- arrival, for a receiver; the moment
|
||||
/// the frame is handed to the transport, for a sender. See [`anchor`](Self::anchor) for why the difference is taken
|
||||
/// in wrapping arithmetic rather than widened.
|
||||
pub fn observe(&mut self, stamp_ms: u32, observed_ms: u64, now: Instant) {
|
||||
let raw = (observed_ms as u32).wrapping_sub(stamp_ms);
|
||||
let anchor = *self.anchor.get_or_insert(raw);
|
||||
let delay = raw.wrapping_sub(anchor) as i32;
|
||||
self.expire(now);
|
||||
|
||||
// Anything at or below the running minimum becomes the new baseline, and
|
||||
// supersedes the candidates it beats -- they can only be worse, so
|
||||
// keeping them would let a stale, higher value resurface on expiry.
|
||||
while self.baseline.back().is_some_and(|(_, d)| *d >= delay) {
|
||||
self.baseline.pop_back();
|
||||
}
|
||||
self.baseline.push_back((now, delay));
|
||||
|
||||
let best = self.baseline.front().map(|(_, d)| *d).unwrap_or(delay);
|
||||
let lateness = i64::from(delay)
|
||||
.saturating_sub(i64::from(best))
|
||||
.clamp(0, i64::from(u16::MAX)) as u16;
|
||||
if self.samples.len() < MAX_SAMPLES {
|
||||
self.samples.push(lateness);
|
||||
}
|
||||
}
|
||||
|
||||
/// Summarise and clear the frames seen since the last call.
|
||||
///
|
||||
/// Returns `None` for a second in which nothing arrived. That is not the
|
||||
/// same as a second with no lateness, and reporting zero would say the path
|
||||
/// is behaving perfectly at the moment it has stopped delivering anything.
|
||||
pub fn take(&mut self) -> Option<DelaySummary> {
|
||||
if self.samples.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.samples.sort_unstable();
|
||||
let at = |q: f64| self.samples[((self.samples.len() - 1) as f64 * q) as usize];
|
||||
let summary = DelaySummary {
|
||||
frames: self.samples.len() as u32,
|
||||
p50_ms: at(0.5),
|
||||
p95_ms: at(0.95),
|
||||
max_ms: self.samples[self.samples.len() - 1],
|
||||
};
|
||||
self.samples.clear();
|
||||
Some(summary)
|
||||
}
|
||||
|
||||
/// Forget the baseline entirely.
|
||||
///
|
||||
/// For a path change, where the old best case says nothing about the new
|
||||
/// path and keeping it would make every frame look late until it expired.
|
||||
pub fn reset_baseline(&mut self) {
|
||||
self.baseline.clear();
|
||||
}
|
||||
|
||||
fn expire(&mut self, now: Instant) {
|
||||
while self
|
||||
.baseline
|
||||
.front()
|
||||
.is_some_and(|(seen, _)| now.duration_since(*seen) > BASELINE_WINDOW)
|
||||
{
|
||||
self.baseline.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A frame sent at `ts_ms` arriving `transit` milliseconds later, where the
|
||||
/// receiver's clock is offset from the sender's by an arbitrary amount.
|
||||
const OFFSET: u64 = 1_700_000_000_000;
|
||||
|
||||
fn at(j: &mut DelayTracker, ts_ms: u32, transit: u64, now: Instant) {
|
||||
j.observe(ts_ms, OFFSET + u64::from(ts_ms) + transit, now);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_constant_delay_is_not_jitter() {
|
||||
// The point of the whole measure. A path that delivers every frame
|
||||
// exactly 300 ms late needs no buffer at all; only variation does.
|
||||
let mut j = DelayTracker::new();
|
||||
let now = Instant::now();
|
||||
for i in 0..120u32 {
|
||||
at(
|
||||
&mut j,
|
||||
i * 16,
|
||||
300,
|
||||
now + Duration::from_millis(u64::from(i) * 16),
|
||||
);
|
||||
}
|
||||
let s = j.take().expect("frames arrived");
|
||||
assert_eq!(s.max_ms, 0, "a constant offset was read as lateness");
|
||||
assert_eq!(s.frames, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lateness_is_measured_against_the_best_the_path_managed() {
|
||||
let mut j = DelayTracker::new();
|
||||
let now = Instant::now();
|
||||
at(&mut j, 0, 20, now);
|
||||
at(&mut j, 16, 70, now + Duration::from_millis(16));
|
||||
at(&mut j, 32, 20, now + Duration::from_millis(32));
|
||||
let s = j.take().expect("frames arrived");
|
||||
assert_eq!(
|
||||
s.max_ms, 50,
|
||||
"the 70 ms frame is 50 ms above the 20 ms best"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_second_reports_nothing_rather_than_no_jitter() {
|
||||
// Zero would say the path is behaving perfectly at the moment it has
|
||||
// stopped delivering anything at all.
|
||||
let mut j = DelayTracker::new();
|
||||
assert_eq!(j.take(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_baseline_expires_so_an_improved_path_is_noticed() {
|
||||
// A minimum that can only fall never recovers: one lucky early frame
|
||||
// would make every later frame look late for the rest of the session.
|
||||
let mut j = DelayTracker::new();
|
||||
let now = Instant::now();
|
||||
at(&mut j, 0, 10, now);
|
||||
j.take();
|
||||
|
||||
// Much later, the path settles at a steady 200 ms.
|
||||
let later = now + BASELINE_WINDOW + Duration::from_secs(1);
|
||||
for i in 0..10u32 {
|
||||
at(
|
||||
&mut j,
|
||||
1000 + i * 16,
|
||||
200,
|
||||
later + Duration::from_millis(u64::from(i) * 16),
|
||||
);
|
||||
}
|
||||
let s = j.take().expect("frames arrived");
|
||||
assert_eq!(
|
||||
s.max_ms, 0,
|
||||
"a stale best case from a previous path made a steady path look late",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_better_observation_supersedes_worse_ones_still_in_the_window() {
|
||||
// Otherwise a higher candidate resurfaces when the better one expires,
|
||||
// and the baseline walks upwards for no reason the path can account for.
|
||||
let mut j = DelayTracker::new();
|
||||
let now = Instant::now();
|
||||
at(&mut j, 0, 90, now);
|
||||
at(&mut j, 16, 10, now + Duration::from_millis(16));
|
||||
// The 90 ms candidate is gone, so this sits 40 ms above the 10 ms best.
|
||||
at(&mut j, 32, 50, now + Duration::from_millis(32));
|
||||
let s = j.take().expect("frames arrived");
|
||||
assert_eq!(s.max_ms, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reset_forgets_the_old_path_entirely() {
|
||||
let mut j = DelayTracker::new();
|
||||
let now = Instant::now();
|
||||
at(&mut j, 0, 10, now);
|
||||
j.take();
|
||||
j.reset_baseline();
|
||||
at(&mut j, 16, 400, now + Duration::from_millis(16));
|
||||
let s = j.take().expect("frames arrived");
|
||||
assert_eq!(s.max_ms, 0, "the new path was judged against the old one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tail_is_kept_apart_from_the_middle() {
|
||||
// The number that sizes a buffer is the tail. A run of prompt frames
|
||||
// with one bad one must not average into "slightly late".
|
||||
let mut j = DelayTracker::new();
|
||||
let now = Instant::now();
|
||||
for i in 0..99u32 {
|
||||
at(
|
||||
&mut j,
|
||||
i * 16,
|
||||
10,
|
||||
now + Duration::from_millis(u64::from(i) * 16),
|
||||
);
|
||||
}
|
||||
at(&mut j, 99 * 16, 260, now + Duration::from_millis(99 * 16));
|
||||
let s = j.take().expect("frames arrived");
|
||||
assert_eq!(s.p50_ms, 0);
|
||||
assert_eq!(s.max_ms, 250);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taking_clears_so_each_answer_describes_one_second() {
|
||||
let mut j = DelayTracker::new();
|
||||
let now = Instant::now();
|
||||
at(&mut j, 0, 10, now);
|
||||
assert!(j.take().is_some());
|
||||
assert_eq!(
|
||||
j.take(),
|
||||
None,
|
||||
"a second reported the previous second again"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrapped_sender_timestamp_does_not_poison_the_baseline() {
|
||||
// ts_ms wraps every 49 days. A negative delay taken as the best case
|
||||
// would make every subsequent frame appear weeks late.
|
||||
let mut j = DelayTracker::new();
|
||||
let now = Instant::now();
|
||||
j.observe(u32::MAX - 10, OFFSET, now);
|
||||
j.observe(5, OFFSET + 16, now + Duration::from_millis(16));
|
||||
let s = j.take().expect("frames arrived");
|
||||
assert!(s.max_ms < u16::MAX, "a wrap produced a nonsense lateness");
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,64 @@
|
||||
// can drift from each other silently.
|
||||
|
||||
pub mod datagram;
|
||||
pub mod delay;
|
||||
pub mod input;
|
||||
#[cfg(feature = "lifecycle")]
|
||||
pub mod lifecycle;
|
||||
pub mod reliable;
|
||||
pub mod stats;
|
||||
|
||||
pub const ALPN: &[u8] = b"/nestri/stream/1";
|
||||
/// One connection per kind of traffic, not one connection for everything.
|
||||
///
|
||||
/// A QUIC connection is the unit that congestion control, pacing and the
|
||||
/// datagram send buffer all operate on, so everything sharing one shares a
|
||||
/// queue. Measured over a 1000-mile link: a video backlog delayed audio and
|
||||
/// input with it, because a backlog is a property of the connection and video
|
||||
/// is the only flow large enough to build one. Audio behind a deep video queue
|
||||
/// was twenty-five times worse for timing than audio behind a shallow one --
|
||||
/// the same audio, on the same path, ruined by what it was queued behind.
|
||||
///
|
||||
/// Splitting them gives each its own congestion controller and its own send
|
||||
/// buffer, so video can only ever delay video. They compete at a shared
|
||||
/// bottleneck rather than cooperating, which is the point: audio and input are
|
||||
/// small and need a share, not a place in line behind a keyframe.
|
||||
pub const ALPN_VIDEO: &[u8] = b"/nestri/video/1";
|
||||
pub const ALPN_AUDIO: &[u8] = b"/nestri/audio/1";
|
||||
pub const ALPN_INPUT: &[u8] = b"/nestri/input/1";
|
||||
pub const ALPN_CONTROL: &[u8] = b"/nestri/control/1";
|
||||
|
||||
/// Every ALPN a hub accepts, for the endpoint builder.
|
||||
pub const ALPNS: [&[u8]; 4] = [ALPN_VIDEO, ALPN_AUDIO, ALPN_INPUT, ALPN_CONTROL];
|
||||
|
||||
/// Which connection an accepted one is, by its ALPN.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Carrier {
|
||||
Video,
|
||||
Audio,
|
||||
Input,
|
||||
Control,
|
||||
}
|
||||
|
||||
impl Carrier {
|
||||
pub fn from_alpn(alpn: &[u8]) -> Option<Self> {
|
||||
match alpn {
|
||||
a if a == ALPN_VIDEO => Some(Self::Video),
|
||||
a if a == ALPN_AUDIO => Some(Self::Audio),
|
||||
a if a == ALPN_INPUT => Some(Self::Input),
|
||||
a if a == ALPN_CONTROL => Some(Self::Control),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Video => "video",
|
||||
Self::Audio => "audio",
|
||||
Self::Input => "input",
|
||||
Self::Control => "control",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const IPC_MAGIC: [u8; 4] = [b'N', b'S', b'T', b'R'];
|
||||
|
||||
@@ -38,8 +89,21 @@ pub const FRAME_HDR_LEN: usize = 7;
|
||||
/// For single-purpose streams (video, audio) the type is redundant but included
|
||||
/// for uniformity.
|
||||
pub const MSG_DATA: u8 = 0; // generic data frame (video / audio)
|
||||
pub const MSG_IDR_REQUEST: u8 = 0x10; // request a keyframe (desktop → hub → hudless)
|
||||
pub const MSG_ENCODE_SETTINGS: u8 = 0x12; // change encoder settings (desktop → hub → hudless)
|
||||
pub const MSG_IDR_REQUEST: u8 = 0x10; // request a keyframe (desktop → hub → nescapture)
|
||||
pub const MSG_ENCODE_SETTINGS: u8 = 0x12; // change encoder settings (desktop → hub → nescapture)
|
||||
pub const MSG_CLIENT_CAPS: u8 = 0x15; // what the client can decode (desktop → hub → nescapture)
|
||||
pub const MSG_SURFACE_COLOR: u8 = 0x16; // what the compositor was told a surface is (nescope → nescapture)
|
||||
/// What the receiver actually got, once a second (desktop → hub).
|
||||
///
|
||||
/// The hub cannot see this. Its own view of the path -- RTT, congestion window,
|
||||
/// whether a datagram send returned an error -- was measured saying the path was
|
||||
/// healthy while the client was receiving almost nothing, and one reason is
|
||||
/// structural: `send_datagram` evicts the oldest queued datagrams and returns
|
||||
/// `Ok`, so the send side has no backpressure signal at all. Only the far end
|
||||
/// knows what arrived.
|
||||
pub const MSG_RECEIVER_REPORT: u8 = 0x13;
|
||||
/// Who decides the bitrate, and the ceiling to decide within (desktop → hub).
|
||||
pub const MSG_CONTROL_MODE: u8 = 0x14;
|
||||
pub const MSG_INPUT_BATCH: u8 = 0xFE; // batched input events (desktop → hub)
|
||||
|
||||
/// Build a frame body: `[u8 type] [u16 LE seq] [payload]`.
|
||||
@@ -75,7 +139,7 @@ pub fn decode_frame(frame: &[u8]) -> Option<(u8, u16, &[u8])> {
|
||||
// ── Stream types ────────────────────────────────────────────────
|
||||
//
|
||||
// These name a media kind, not a transport. `STREAM_VIDEO` and `STREAM_AUDIO`
|
||||
// still tag IPC frames on the hudless→hub unix sockets, but over QUIC their
|
||||
// still tag IPC frames on the nescapture→hub unix sockets, but over QUIC their
|
||||
// media now travels as datagrams; only cursor and stats still open a uni
|
||||
// stream and send this as its first byte.
|
||||
|
||||
@@ -90,6 +154,13 @@ pub const STREAM_KEYFRAME: u8 = 5;
|
||||
// ── Bidi stream types (desktop ↔ hub over QUIC bidi) ───────────
|
||||
|
||||
pub const BIDI_INPUT: u8 = 2; // desktop → hub → nescope (input events)
|
||||
/// Everything the client says that is not an input event: keyframe requests,
|
||||
/// receiver reports, encode settings, control mode.
|
||||
///
|
||||
/// Its own stream on its own connection. Input is small and latency-critical
|
||||
/// and must not wait behind a receiver report, and neither must wait behind
|
||||
/// video, which is why these live apart from the media connections entirely.
|
||||
pub const BIDI_CONTROL: u8 = 6;
|
||||
|
||||
// Codec IDs
|
||||
pub const CODEC_H264: u8 = 0;
|
||||
@@ -101,6 +172,13 @@ pub const CODEC_KEEP: u8 = 0xFF; // "keep current" sentinel for dynamic encoder
|
||||
// Rate control modes (encode settings)
|
||||
pub const RC_CBR: u8 = 0;
|
||||
pub const RC_CQP: u8 = 1;
|
||||
/// "Keep current" sentinel, the rate-control counterpart of [`CODEC_KEEP`].
|
||||
///
|
||||
/// A settings message says four things at once, and until this existed there
|
||||
/// was no way to say only one of them: a client wanting a different bit depth
|
||||
/// had to name a rate control mode and a value too, which in Auto mode means
|
||||
/// overruling the controller that owns the bitrate.
|
||||
pub const RC_KEEP: u8 = 0xFF;
|
||||
|
||||
// Bit-depth (encode settings)
|
||||
pub const DEPTH_8: u8 = 0;
|
||||
@@ -208,6 +286,207 @@ pub fn encode_encode_settings(
|
||||
buf.push(bit_depth);
|
||||
}
|
||||
|
||||
/// A settings payload that changes the bitrate and nothing else.
|
||||
///
|
||||
/// Six bytes rather than seven: the bit-depth byte is *omitted*, which
|
||||
/// [`decode_encode_settings`] reports as `None`. That matters at the far end,
|
||||
/// where a change carrying a depth has to be treated as a possible depth change
|
||||
/// and rebuild the video session -- and a rebuild costs a keyframe. A controller
|
||||
/// adjusting the bitrate every second must not do that, so it says nothing it
|
||||
/// does not mean: keep the codec, keep the depth, this bitrate.
|
||||
pub fn encode_bitrate_only(buf: &mut Vec<u8>, kbps: u32) {
|
||||
buf.reserve(6);
|
||||
buf.push(CODEC_KEEP);
|
||||
buf.push(RC_CBR);
|
||||
buf.extend_from_slice(&kbps.to_le_bytes());
|
||||
}
|
||||
|
||||
// ── What colour the compositor was told a surface is ────────────────────
|
||||
|
||||
/// SDR: BT.709 primaries, sRGB transfer.
|
||||
pub const SURFACE_COLOR_SRGB: u8 = 0;
|
||||
/// HDR10: BT.2020 primaries, PQ transfer.
|
||||
pub const SURFACE_COLOR_BT2020_PQ: u8 = 1;
|
||||
|
||||
/// What a Wayland client declared about its surface's colour.
|
||||
///
|
||||
/// Capture normally reads the colour space from the game's Vulkan swapchain,
|
||||
/// and that is the right source when the swapchain names one. It does not
|
||||
/// always: `VK_COLOR_SPACE_PASS_THROUGH_EXT` means "do not convert my values"
|
||||
/// and carries no colour information at all, while the surface's real colour
|
||||
/// space is declared separately, over `wp_color_manager_v1`, to the
|
||||
/// compositor. A Windows title turning on HDR through wine arrives exactly
|
||||
/// that way -- the pixels are BT.2020 PQ and the swapchain says nothing.
|
||||
///
|
||||
/// So the compositor, which is told, passes it to capture, which is not.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct SurfaceColor {
|
||||
/// One of the `SURFACE_COLOR_*` constants.
|
||||
pub space: u8,
|
||||
/// Mastering metadata, as the client gave it. Zero where it said nothing.
|
||||
///
|
||||
/// Carried now and not yet applied: it belongs in the stream's own
|
||||
/// metadata, and sending it from the start means that can be wired up
|
||||
/// without a second protocol change and a second pin.
|
||||
pub max_cll: u32,
|
||||
pub max_fall: u32,
|
||||
pub min_luminance: u32,
|
||||
pub max_luminance: u32,
|
||||
}
|
||||
|
||||
/// Encode a surface colour declaration:
|
||||
/// `[1B space] [4B max_cll] [4B max_fall] [4B min_lum] [4B max_lum]`, LE.
|
||||
pub fn encode_surface_color(buf: &mut Vec<u8>, colour: &SurfaceColor) {
|
||||
buf.reserve(17);
|
||||
buf.push(colour.space);
|
||||
buf.extend_from_slice(&colour.max_cll.to_le_bytes());
|
||||
buf.extend_from_slice(&colour.max_fall.to_le_bytes());
|
||||
buf.extend_from_slice(&colour.min_luminance.to_le_bytes());
|
||||
buf.extend_from_slice(&colour.max_luminance.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Decode one. `None` when the payload is too short to be one.
|
||||
pub fn decode_surface_color(payload: &[u8]) -> Option<SurfaceColor> {
|
||||
if payload.len() < 17 {
|
||||
return None;
|
||||
}
|
||||
let u32_at =
|
||||
|i: usize| u32::from_le_bytes([payload[i], payload[i + 1], payload[i + 2], payload[i + 3]]);
|
||||
Some(SurfaceColor {
|
||||
space: payload[0],
|
||||
max_cll: u32_at(1),
|
||||
max_fall: u32_at(5),
|
||||
min_luminance: u32_at(9),
|
||||
max_luminance: u32_at(13),
|
||||
})
|
||||
}
|
||||
|
||||
// ── What the client can decode ──────────────────────────────────────────
|
||||
|
||||
/// The codec and depth combinations a client can decode, as one bitmask.
|
||||
///
|
||||
/// A host that picks something the far end cannot decode produces a black
|
||||
/// screen and no error, so it needs the client's whole set rather than its
|
||||
/// favourite: knowing only "this one prefers AV1" leaves nowhere to fall back
|
||||
/// to when the host cannot encode AV1 either.
|
||||
///
|
||||
/// Sent once, on connect, before any picture. That is early enough that the
|
||||
/// host never sends a codec the client cannot read, which reacting to the
|
||||
/// first frame could not manage.
|
||||
///
|
||||
/// One bit per pair, at `codec * 2 + depth`, so the layout follows from the
|
||||
/// codec ids rather than from a table that can disagree with them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct ClientCaps(u16);
|
||||
|
||||
/// Best first. The same order the host uses to pick among its own encoders,
|
||||
/// stated once so the two cannot drift apart.
|
||||
pub const CODEC_PREFERENCE: [u8; 3] = [CODEC_AV1, CODEC_H265, CODEC_H264];
|
||||
|
||||
impl ClientCaps {
|
||||
/// Nothing supported. What a client that never spoke is assumed to have,
|
||||
/// which is why [`Self::best`] treats an empty set as "no opinion" rather
|
||||
/// than as "decodes nothing".
|
||||
pub fn empty() -> Self {
|
||||
Self(0)
|
||||
}
|
||||
|
||||
pub fn from_bits(bits: u16) -> Self {
|
||||
Self(bits)
|
||||
}
|
||||
|
||||
pub fn bits(self) -> u16 {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn is_empty(self) -> bool {
|
||||
self.0 == 0
|
||||
}
|
||||
|
||||
fn bit(codec: u8, depth: u8) -> Option<u16> {
|
||||
// `CODEC_KEEP` and the audio codec have no place in a video capability
|
||||
// set, and shifting by them would be nonsense rather than a small
|
||||
// error.
|
||||
if !matches!(codec, CODEC_H264 | CODEC_H265 | CODEC_AV1) {
|
||||
return None;
|
||||
}
|
||||
if !matches!(depth, DEPTH_8 | DEPTH_10) {
|
||||
return None;
|
||||
}
|
||||
Some(1u16 << (codec * 2 + depth))
|
||||
}
|
||||
|
||||
/// Add one pair. Unknown codecs and depths are ignored rather than
|
||||
/// panicking: this is built from what a device probe reported.
|
||||
#[must_use]
|
||||
pub fn with(mut self, codec: u8, depth: u8) -> Self {
|
||||
if let Some(bit) = Self::bit(codec, depth) {
|
||||
self.0 |= bit;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn supports(self, codec: u8, depth: u8) -> bool {
|
||||
Self::bit(codec, depth).is_some_and(|bit| self.0 & bit != 0)
|
||||
}
|
||||
|
||||
/// Whether this set can decode `codec` at any depth.
|
||||
pub fn supports_codec(self, codec: u8) -> bool {
|
||||
self.supports(codec, DEPTH_8) || self.supports(codec, DEPTH_10)
|
||||
}
|
||||
|
||||
/// The best codec and depth both ends can manage.
|
||||
///
|
||||
/// Walks [`CODEC_PREFERENCE`], takes the first codec present in both sets,
|
||||
/// and within it prefers ten bits -- deeper coefficients carry less
|
||||
/// rounding error through the transform, so it is usually a small win on
|
||||
/// efficiency rather than a trade against it.
|
||||
///
|
||||
/// `None` when nothing overlaps, which is a real possibility rather than a
|
||||
/// theoretical one: an old client that sends no capabilities at all reads
|
||||
/// as empty. The caller keeps whatever it was already doing.
|
||||
pub fn best(self, host: Self) -> Option<(u8, u8)> {
|
||||
if self.is_empty() || host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
for codec in CODEC_PREFERENCE {
|
||||
for depth in [DEPTH_10, DEPTH_8] {
|
||||
if self.supports(codec, depth) && host.supports(codec, depth) {
|
||||
return Some((codec, depth));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a capability set: `[2B bits LE]`.
|
||||
pub fn encode_client_caps(buf: &mut Vec<u8>, caps: ClientCaps) {
|
||||
buf.extend_from_slice(&caps.bits().to_le_bytes());
|
||||
}
|
||||
|
||||
/// Decode a capability set. `None` when the payload is too short to be one.
|
||||
pub fn decode_client_caps(payload: &[u8]) -> Option<ClientCaps> {
|
||||
if payload.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some(ClientCaps::from_bits(u16::from_le_bytes([
|
||||
payload[0], payload[1],
|
||||
])))
|
||||
}
|
||||
|
||||
/// A settings payload that changes the bit depth and nothing else.
|
||||
///
|
||||
/// What a client sends once, on connect, to say what it can actually decode.
|
||||
/// The codec and the rate control are both left alone, so this is safe to send
|
||||
/// in Auto mode, where the controller owns the bitrate.
|
||||
///
|
||||
/// Seven bytes, because the depth byte is the seventh: see
|
||||
/// [`encode_bitrate_only`] for why its absence means something.
|
||||
pub fn encode_depth_only(buf: &mut Vec<u8>, bit_depth: u8) {
|
||||
encode_encode_settings(buf, CODEC_KEEP, RC_KEEP, 0, bit_depth);
|
||||
}
|
||||
|
||||
/// Decode an encode-settings payload. Returns `(codec_id, rate_control_mode, value, bit_depth)`.
|
||||
/// bit_depth is None for 6-byte (old client) payloads, Some(n) for 7+ byte payloads.
|
||||
pub fn decode_encode_settings(payload: &[u8]) -> Option<(u8, u8, u32, Option<u8>)> {
|
||||
@@ -224,3 +503,439 @@ pub fn decode_encode_settings(payload: &[u8]) -> Option<(u8, u8, u32, Option<u8>
|
||||
};
|
||||
Some((codec_id, rc, value, depth))
|
||||
}
|
||||
|
||||
// ── Receiver report ─────────────────────────────────────────────
|
||||
|
||||
/// What one second looked like from the receiving end.
|
||||
///
|
||||
/// Counts are per-second deltas, not totals: a controller wants to know what is
|
||||
/// happening now, and a total makes every reading depend on how long the session
|
||||
/// has been running.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct ReceiverReport {
|
||||
/// Video bits per second actually reassembled and released to the decoder.
|
||||
///
|
||||
/// Not what was sent, and not what arrived -- what *completed*. A frame
|
||||
/// missing one fragment contributes nothing here, which is right: it
|
||||
/// contributed nothing to the picture either. When the sender is saturating
|
||||
/// the path this is the measured capacity of it.
|
||||
pub goodput_bps: u64,
|
||||
/// Frames released to the decoder.
|
||||
pub released: u32,
|
||||
/// Frames that started arriving and never completed.
|
||||
pub incomplete: u32,
|
||||
/// Frames no fragment of which ever arrived.
|
||||
pub never_arrived: u32,
|
||||
/// The receiver's own round-trip estimate, in milliseconds.
|
||||
pub rtt_ms: u32,
|
||||
}
|
||||
|
||||
impl ReceiverReport {
|
||||
/// The fraction of frames that did not make it, in `0.0..=1.0`.
|
||||
///
|
||||
/// `None` when no frames were accounted for at all, which is not the same
|
||||
/// as no loss -- a second in which nothing was sent and a second in which
|
||||
/// nothing arrived look identical here, and only the caller knows which it
|
||||
/// is expecting.
|
||||
pub fn loss(&self) -> Option<f32> {
|
||||
let total = self.released + self.incomplete + self.never_arrived;
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((self.incomplete + self.never_arrived) as f32 / total as f32)
|
||||
}
|
||||
}
|
||||
|
||||
/// `[8B goodput_bps][4B released][4B incomplete][4B never_arrived][4B rtt_ms]`,
|
||||
/// all little-endian.
|
||||
pub const RECEIVER_REPORT_LEN: usize = 24;
|
||||
|
||||
pub fn encode_receiver_report(buf: &mut Vec<u8>, report: &ReceiverReport) {
|
||||
buf.reserve(RECEIVER_REPORT_LEN);
|
||||
buf.extend_from_slice(&report.goodput_bps.to_le_bytes());
|
||||
buf.extend_from_slice(&report.released.to_le_bytes());
|
||||
buf.extend_from_slice(&report.incomplete.to_le_bytes());
|
||||
buf.extend_from_slice(&report.never_arrived.to_le_bytes());
|
||||
buf.extend_from_slice(&report.rtt_ms.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Decode a receiver report. `None` when the payload is short.
|
||||
///
|
||||
/// A payload *longer* than expected is accepted and its tail ignored, so a newer
|
||||
/// client that appends a field still reports usefully to an older hub.
|
||||
pub fn decode_receiver_report(payload: &[u8]) -> Option<ReceiverReport> {
|
||||
if payload.len() < RECEIVER_REPORT_LEN {
|
||||
return None;
|
||||
}
|
||||
let u32_at = |o: usize| u32::from_le_bytes(payload[o..o + 4].try_into().unwrap());
|
||||
Some(ReceiverReport {
|
||||
goodput_bps: u64::from_le_bytes(payload[0..8].try_into().unwrap()),
|
||||
released: u32_at(8),
|
||||
incomplete: u32_at(12),
|
||||
never_arrived: u32_at(16),
|
||||
rtt_ms: u32_at(20),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Control mode ────────────────────────────────────────────────
|
||||
|
||||
/// Who is choosing the bitrate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ControlMode {
|
||||
/// The hub's controller decides, within the ceiling.
|
||||
#[default]
|
||||
Auto,
|
||||
/// A person decided, and the controller stands down until told otherwise.
|
||||
///
|
||||
/// Kept because it is how this class of bug gets diagnosed at all: the
|
||||
/// original "the bitrate is already lowered" report was wrong, and the only
|
||||
/// way anyone established that was by setting one by hand and watching the
|
||||
/// picture come back.
|
||||
Manual,
|
||||
}
|
||||
|
||||
pub const CONTROL_MODE_AUTO: u8 = 0;
|
||||
pub const CONTROL_MODE_MANUAL: u8 = 1;
|
||||
|
||||
/// `[1B mode][4B ceiling_kbps LE]`. A ceiling of 0 means "no opinion, keep
|
||||
/// whatever the hub was given".
|
||||
pub const CONTROL_MODE_LEN: usize = 5;
|
||||
|
||||
pub fn encode_control_mode(buf: &mut Vec<u8>, mode: ControlMode, ceiling_kbps: u32) {
|
||||
buf.reserve(CONTROL_MODE_LEN);
|
||||
buf.push(match mode {
|
||||
ControlMode::Auto => CONTROL_MODE_AUTO,
|
||||
ControlMode::Manual => CONTROL_MODE_MANUAL,
|
||||
});
|
||||
buf.extend_from_slice(&ceiling_kbps.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Returns `(mode, ceiling_kbps)`; the ceiling is `None` when it was left at 0.
|
||||
pub fn decode_control_mode(payload: &[u8]) -> Option<(ControlMode, Option<u32>)> {
|
||||
if payload.len() < CONTROL_MODE_LEN {
|
||||
return None;
|
||||
}
|
||||
let mode = match payload[0] {
|
||||
CONTROL_MODE_AUTO => ControlMode::Auto,
|
||||
CONTROL_MODE_MANUAL => ControlMode::Manual,
|
||||
// An unknown mode is not a reason to stop controlling the bitrate, and
|
||||
// guessing "manual" would silently disable the controller.
|
||||
_ => return None,
|
||||
};
|
||||
let ceiling = u32::from_le_bytes(payload[1..5].try_into().unwrap());
|
||||
Some((mode, (ceiling != 0).then_some(ceiling)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod media_control_tests {
|
||||
use super::*;
|
||||
|
||||
fn report() -> ReceiverReport {
|
||||
ReceiverReport {
|
||||
goodput_bps: 2_850_000,
|
||||
released: 47,
|
||||
incomplete: 12,
|
||||
never_arrived: 1,
|
||||
rtt_ms: 182,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_report_survives_the_wire() {
|
||||
let mut buf = Vec::new();
|
||||
encode_receiver_report(&mut buf, &report());
|
||||
assert_eq!(buf.len(), RECEIVER_REPORT_LEN);
|
||||
assert_eq!(decode_receiver_report(&buf), Some(report()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_report_is_refused_rather_than_guessed() {
|
||||
let mut buf = Vec::new();
|
||||
encode_receiver_report(&mut buf, &report());
|
||||
for n in 0..RECEIVER_REPORT_LEN {
|
||||
assert_eq!(decode_receiver_report(&buf[..n]), None, "{n} bytes");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_longer_report_is_read_and_its_tail_ignored() {
|
||||
// So a newer client that appends a field still reports usefully to a
|
||||
// hub that predates it.
|
||||
let mut buf = Vec::new();
|
||||
encode_receiver_report(&mut buf, &report());
|
||||
buf.extend_from_slice(&[0xAA; 8]);
|
||||
assert_eq!(decode_receiver_report(&buf), Some(report()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loss_counts_every_frame_that_did_not_arrive_whole() {
|
||||
// An incomplete frame is a lost frame. It cost bandwidth and produced no
|
||||
// picture, which is worse than never having been sent.
|
||||
let r = ReceiverReport {
|
||||
released: 90,
|
||||
incomplete: 8,
|
||||
never_arrived: 2,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(r.loss(), Some(0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_second_has_no_loss_figure() {
|
||||
// Nothing sent and nothing arrived look identical from here. Reporting
|
||||
// 0% would tell a controller the path is healthy; reporting 100% would
|
||||
// tell it to collapse the bitrate. Neither is known, so neither is said.
|
||||
assert_eq!(ReceiverReport::default().loss(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_loss_is_reported_as_total() {
|
||||
let r = ReceiverReport {
|
||||
released: 0,
|
||||
incomplete: 46,
|
||||
never_arrived: 14,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(r.loss(), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_mode_survives_the_wire() {
|
||||
for (mode, ceiling) in [
|
||||
(ControlMode::Auto, 8_000u32),
|
||||
(ControlMode::Manual, 1_000),
|
||||
(ControlMode::Auto, 0),
|
||||
] {
|
||||
let mut buf = Vec::new();
|
||||
encode_control_mode(&mut buf, mode, ceiling);
|
||||
assert_eq!(buf.len(), CONTROL_MODE_LEN);
|
||||
assert_eq!(
|
||||
decode_control_mode(&buf),
|
||||
Some((mode, (ceiling != 0).then_some(ceiling))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_mode_is_refused_rather_than_defaulted() {
|
||||
// Defaulting to manual would silently switch the controller off, which
|
||||
// is the failure this whole change exists to remove.
|
||||
let mut buf = vec![0x7F];
|
||||
buf.extend_from_slice(&8_000u32.to_le_bytes());
|
||||
assert_eq!(decode_control_mode(&buf), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_surface_colour_survives_the_wire() {
|
||||
let colour = SurfaceColor {
|
||||
space: SURFACE_COLOR_BT2020_PQ,
|
||||
max_cll: 1000,
|
||||
max_fall: 400,
|
||||
min_luminance: 0,
|
||||
max_luminance: 1000,
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
encode_surface_color(&mut buf, &colour);
|
||||
assert_eq!(buf.len(), 17);
|
||||
assert_eq!(decode_surface_color(&buf), Some(colour));
|
||||
}
|
||||
|
||||
/// A truncated payload is not a surface that is suddenly SDR. Reading one
|
||||
/// as though it were would turn a dropped byte into a wrong picture.
|
||||
#[test]
|
||||
fn a_short_surface_colour_is_not_read() {
|
||||
let mut buf = Vec::new();
|
||||
encode_surface_color(&mut buf, &SurfaceColor::default());
|
||||
for len in 0..17 {
|
||||
assert_eq!(decode_surface_color(&buf[..len]), None, "len {len}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_message_type_is_its_own_number() {
|
||||
// Every type byte that travels on a stream, as `(name, value)`. Listed
|
||||
// by hand because the point is to catch a new one colliding with an
|
||||
// existing one, and anything derived from the constants would agree
|
||||
// with them by construction.
|
||||
//
|
||||
// `MSG_CLIENT_CAPS` was 0x13 when it was added, which is
|
||||
// `MSG_RECEIVER_REPORT`. The hub matches on the type byte and the caps
|
||||
// arm came first, so every receiver report would have been read as
|
||||
// capabilities - taking away the only measurement the bitrate
|
||||
// controller has, silently, on a message sent once per connection.
|
||||
let types = [
|
||||
("MSG_DATA", MSG_DATA),
|
||||
("MSG_IDR_REQUEST", MSG_IDR_REQUEST),
|
||||
("MSG_ENCODE_SETTINGS", MSG_ENCODE_SETTINGS),
|
||||
("MSG_CLIENT_CAPS", MSG_CLIENT_CAPS),
|
||||
("MSG_SURFACE_COLOR", MSG_SURFACE_COLOR),
|
||||
("MSG_RECEIVER_REPORT", MSG_RECEIVER_REPORT),
|
||||
("MSG_CONTROL_MODE", MSG_CONTROL_MODE),
|
||||
("MSG_INPUT_BATCH", MSG_INPUT_BATCH),
|
||||
];
|
||||
for (i, (name, value)) in types.iter().enumerate() {
|
||||
for (other_name, other_value) in &types[i + 1..] {
|
||||
assert_ne!(
|
||||
value, other_value,
|
||||
"{name} and {other_name} are both {value:#04x}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_codec_and_depth_has_its_own_bit() {
|
||||
let all = [CODEC_H264, CODEC_H265, CODEC_AV1]
|
||||
.into_iter()
|
||||
.flat_map(|c| [DEPTH_8, DEPTH_10].map(move |d| (c, d)));
|
||||
let mut seen = Vec::new();
|
||||
for (codec, depth) in all {
|
||||
let caps = ClientCaps::empty().with(codec, depth);
|
||||
assert!(caps.supports(codec, depth));
|
||||
assert!(!seen.contains(&caps.bits()), "{codec}/{depth} collides");
|
||||
seen.push(caps.bits());
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing outside the video codecs belongs in a capability set, and a
|
||||
/// shift by `CODEC_KEEP` would be nonsense rather than a small error.
|
||||
#[test]
|
||||
fn nonsense_pairs_are_ignored_rather_than_stored() {
|
||||
let caps = ClientCaps::empty()
|
||||
.with(CODEC_KEEP, DEPTH_8)
|
||||
.with(CODEC_OPUS, DEPTH_8)
|
||||
.with(CODEC_AV1, 7);
|
||||
assert!(caps.is_empty());
|
||||
assert!(!caps.supports(CODEC_KEEP, DEPTH_8));
|
||||
}
|
||||
|
||||
fn host_all() -> ClientCaps {
|
||||
ClientCaps::empty()
|
||||
.with(CODEC_AV1, DEPTH_8)
|
||||
.with(CODEC_AV1, DEPTH_10)
|
||||
.with(CODEC_H265, DEPTH_8)
|
||||
.with(CODEC_H265, DEPTH_10)
|
||||
.with(CODEC_H264, DEPTH_8)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_best_shared_codec_wins_at_the_deeper_depth() {
|
||||
assert_eq!(host_all().best(host_all()), Some((CODEC_AV1, DEPTH_10)));
|
||||
}
|
||||
|
||||
/// The case this exists for: a client with no AV1 decoder must not be sent
|
||||
/// AV1 just because the host prefers it.
|
||||
#[test]
|
||||
fn a_client_without_av1_gets_h265() {
|
||||
let client = ClientCaps::empty()
|
||||
.with(CODEC_H265, DEPTH_8)
|
||||
.with(CODEC_H265, DEPTH_10)
|
||||
.with(CODEC_H264, DEPTH_8);
|
||||
assert_eq!(client.best(host_all()), Some((CODEC_H265, DEPTH_10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_with_only_h264_gets_h264() {
|
||||
let client = ClientCaps::empty().with(CODEC_H264, DEPTH_8);
|
||||
assert_eq!(client.best(host_all()), Some((CODEC_H264, DEPTH_8)));
|
||||
}
|
||||
|
||||
/// Ten bits is preferred, not required: a client that decodes H.265 at
|
||||
/// eight bits only still gets H.265 rather than being pushed to H.264.
|
||||
#[test]
|
||||
fn eight_bit_is_taken_when_that_is_all_there_is() {
|
||||
let client = ClientCaps::empty()
|
||||
.with(CODEC_H265, DEPTH_8)
|
||||
.with(CODEC_H264, DEPTH_8);
|
||||
assert_eq!(client.best(host_all()), Some((CODEC_H265, DEPTH_8)));
|
||||
}
|
||||
|
||||
/// A host that can only encode AV1 and a client that cannot decode it
|
||||
/// share nothing. The caller keeps what it was doing rather than picking
|
||||
/// something neither end asked for.
|
||||
#[test]
|
||||
fn no_overlap_is_no_answer() {
|
||||
let host = ClientCaps::empty().with(CODEC_AV1, DEPTH_8);
|
||||
let client = ClientCaps::empty().with(CODEC_H264, DEPTH_8);
|
||||
assert_eq!(client.best(host), None);
|
||||
}
|
||||
|
||||
/// A client that never sent capabilities reads as empty, which must mean
|
||||
/// "said nothing" and not "decodes nothing".
|
||||
#[test]
|
||||
fn silence_is_not_an_answer_either() {
|
||||
assert_eq!(ClientCaps::empty().best(host_all()), None);
|
||||
assert_eq!(host_all().best(ClientCaps::empty()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_survive_the_wire() {
|
||||
let caps = ClientCaps::empty()
|
||||
.with(CODEC_AV1, DEPTH_10)
|
||||
.with(CODEC_H264, DEPTH_8);
|
||||
let mut buf = Vec::new();
|
||||
encode_client_caps(&mut buf, caps);
|
||||
assert_eq!(decode_client_caps(&buf), Some(caps));
|
||||
assert_eq!(decode_client_caps(&buf[..1]), None, "too short to read");
|
||||
}
|
||||
|
||||
/// The host walks its own encoders in this order; stating it once is what
|
||||
/// keeps the two ends agreeing about what "best" means.
|
||||
#[test]
|
||||
fn the_preference_order_is_av1_first() {
|
||||
assert_eq!(CODEC_PREFERENCE, [CODEC_AV1, CODEC_H265, CODEC_H264]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_depth_only_change_touches_nothing_else() {
|
||||
let mut buf = Vec::new();
|
||||
encode_depth_only(&mut buf, DEPTH_10);
|
||||
let (codec, rc, value, depth) = decode_encode_settings(&buf).expect("readable");
|
||||
assert_eq!(codec, CODEC_KEEP, "the codec is the host's business");
|
||||
assert_eq!(rc, RC_KEEP, "the controller keeps the bitrate it chose");
|
||||
assert_eq!(value, 0, "and there is no value to read");
|
||||
assert_eq!(depth, Some(DEPTH_10), "the depth is the whole message");
|
||||
}
|
||||
|
||||
/// The two sentinels have to be distinguishable from real values, or a
|
||||
/// "keep this" reads as a request for something.
|
||||
#[test]
|
||||
fn the_keep_sentinels_are_not_real_settings() {
|
||||
assert_ne!(RC_KEEP, RC_CBR);
|
||||
assert_ne!(RC_KEEP, RC_CQP);
|
||||
assert_ne!(CODEC_KEEP, CODEC_H264);
|
||||
assert_ne!(CODEC_KEEP, CODEC_H265);
|
||||
assert_ne!(CODEC_KEEP, CODEC_AV1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bitrate_only_change_carries_no_depth_and_no_codec() {
|
||||
// The far end rebuilds its video session -- and spends a keyframe -- for
|
||||
// anything that might be a codec or depth change. A controller nudging
|
||||
// the bitrate every second must say neither.
|
||||
let mut buf = Vec::new();
|
||||
encode_bitrate_only(&mut buf, 2_500);
|
||||
let (codec, rc, value, depth) = decode_encode_settings(&buf).expect("readable");
|
||||
assert_eq!(codec, CODEC_KEEP);
|
||||
assert_eq!(rc, RC_CBR);
|
||||
assert_eq!(value, 2_500);
|
||||
assert_eq!(depth, None, "a depth byte would force a rebuild");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_message_types_do_not_collide() {
|
||||
let all = [
|
||||
MSG_DATA,
|
||||
MSG_IDR_REQUEST,
|
||||
MSG_ENCODE_SETTINGS,
|
||||
MSG_RECEIVER_REPORT,
|
||||
MSG_CONTROL_MODE,
|
||||
MSG_INPUT_BATCH,
|
||||
];
|
||||
for (i, a) in all.iter().enumerate() {
|
||||
for b in &all[i + 1..] {
|
||||
assert_ne!(a, b, "two message types share a value");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,10 @@ pub const CONTROL_PORT: u32 = 7000;
|
||||
/// messages, so a version-2 peer and a version-3 peer do not talk at all.
|
||||
/// There is deliberately no shim: nothing is deployed, and a shim would be the
|
||||
/// second definition of this wire that one shared crate exists to prevent.
|
||||
pub const CONTROL_VERSION: u32 = 3;
|
||||
///
|
||||
/// Version 4 replaced the descriptor's `drives` with `overlays`: a build is a
|
||||
/// read-only image now, and a box writes into a layer of its own over it.
|
||||
pub const CONTROL_VERSION: u32 = 4;
|
||||
|
||||
/// The command to run, and who runs it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -101,15 +104,29 @@ pub struct Mount {
|
||||
pub ro: bool,
|
||||
}
|
||||
|
||||
/// A block device the guest mounts, rather than a share it is handed.
|
||||
/// A read-only block device with a writable one layered over it.
|
||||
///
|
||||
/// There are no mount options on this and that is deliberate: what the guest
|
||||
/// mounts a build volume with is a property of how the volume was built --
|
||||
/// journal-less ext4, `nosuid`, `nodev` -- and not something a descriptor is in
|
||||
/// a position to know. A field for options was here and was never read.
|
||||
/// This is how a game's install reaches a box. The build is an EROFS image
|
||||
/// every box on the host shares, and the box's own writes land on an ext4 of
|
||||
/// its own that is thrown away with it; the guest stacks the two with
|
||||
/// overlayfs, so a game that writes into its install directory works and the
|
||||
/// build under it cannot change.
|
||||
///
|
||||
/// Stacked in the guest rather than on the host because the host has no
|
||||
/// filesystem it can do it on: the host-side equivalent was a ZFS clone, and a
|
||||
/// host that needs ZFS is what this replaced.
|
||||
///
|
||||
/// There are no mount options on this and that is deliberate: how each layer
|
||||
/// is mounted is a property of how it was built -- EROFS read-only, a
|
||||
/// journal-less ext4 upper, `nosuid`, `nodev` -- and not something a
|
||||
/// descriptor is in a position to know.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Drive {
|
||||
pub dev: String,
|
||||
pub struct Overlay {
|
||||
/// The read-only build image, as the guest names the device.
|
||||
pub lower: String,
|
||||
/// The box's writable layer, as the guest names the device.
|
||||
pub upper: String,
|
||||
/// Where the stacked result lands.
|
||||
pub at: String,
|
||||
}
|
||||
|
||||
@@ -178,7 +195,40 @@ pub struct BootDescriptor {
|
||||
#[serde(default)]
|
||||
pub mounts: Vec<Mount>,
|
||||
#[serde(default)]
|
||||
pub drives: Vec<Drive>,
|
||||
pub overlays: Vec<Overlay>,
|
||||
/// What the box may spend on video.
|
||||
///
|
||||
/// Here rather than on a launch because its consumer is `neshub`, which is
|
||||
/// a service and comes up with the box. Geometry went the other way for the
|
||||
/// same reason: its consumer is the compositor, which is started per launch.
|
||||
/// A number travels to where the thing that reads it is started.
|
||||
#[serde(default)]
|
||||
pub video: VideoLimits,
|
||||
}
|
||||
|
||||
/// Ceilings on what a box's video may cost.
|
||||
///
|
||||
/// A struct rather than a bare number so the next video-shaped limit joins it
|
||||
/// instead of arriving loose alongside it.
|
||||
///
|
||||
/// Note what `deny_unknown_fields` on [`BootDescriptor`] means for this: a host
|
||||
/// that sends `video` to a guest too old to know the field is *refused*, not
|
||||
/// quietly accepted. That is the intended direction of failure -- the
|
||||
/// alternative is a box that comes up, streams, and ignores the ceiling it was
|
||||
/// given, which is exactly the shape of failure this stack produces too easily.
|
||||
/// The guest image is rebuilt before a host starts sending it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct VideoLimits {
|
||||
/// Ceiling on the video bitrate, in kbps.
|
||||
///
|
||||
/// `None` means nobody said, which is not the same as zero and is not the
|
||||
/// same as unlimited. A reader that was told nothing should say so and pick
|
||||
/// a conservative default of its own; a reader that treats "unsaid" as
|
||||
/// "unlimited" reproduces the bug this exists to fix, where every session
|
||||
/// offered 10 Mbps because nothing had ever set a number.
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub bitrate_kbps: Option<u32>,
|
||||
}
|
||||
|
||||
/// How a workload ended.
|
||||
@@ -390,7 +440,8 @@ mod tests {
|
||||
at: "/mnt/install".into(),
|
||||
ro: true,
|
||||
}],
|
||||
drives: Vec::new(),
|
||||
overlays: Vec::new(),
|
||||
video: VideoLimits::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,3 +684,64 @@ mod tests {
|
||||
assert!(!on_exit.terminal);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod video_limits_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_descriptor_without_video_still_reads() {
|
||||
// An older host says nothing about video. That has to keep working, and
|
||||
// it has to be distinguishable from a host that said "no limit".
|
||||
let d: BootDescriptor = serde_json::from_str(r#"{"mounts":[],"overlays":[]}"#).unwrap();
|
||||
assert_eq!(d.video.bitrate_kbps, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsaid_ceiling_is_not_serialised() {
|
||||
// So a host that has nothing to say produces the same bytes it always
|
||||
// did, and an older guest keeps accepting it.
|
||||
let d = BootDescriptor {
|
||||
mounts: Vec::new(),
|
||||
overlays: Vec::new(),
|
||||
video: VideoLimits::default(),
|
||||
};
|
||||
let json = serde_json::to_string(&d).unwrap();
|
||||
assert!(!json.contains("bitrate"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ceiling_survives_the_round_trip() {
|
||||
let d = BootDescriptor {
|
||||
mounts: Vec::new(),
|
||||
overlays: Vec::new(),
|
||||
video: VideoLimits {
|
||||
bitrate_kbps: Some(8_000),
|
||||
},
|
||||
};
|
||||
let back: BootDescriptor =
|
||||
serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
|
||||
assert_eq!(back, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_is_not_the_same_as_unsaid() {
|
||||
// A reader that conflates them cannot tell "the host wants no video" from
|
||||
// "the host never mentioned it", and the second must not be read as a
|
||||
// licence to send whatever it likes.
|
||||
let said: BootDescriptor = serde_json::from_str(r#"{"video":{"bitrate_kbps":0}}"#).unwrap();
|
||||
let unsaid: BootDescriptor = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(said.video.bitrate_kbps, Some(0));
|
||||
assert_eq!(unsaid.video.bitrate_kbps, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_video_field_is_refused() {
|
||||
// Same reasoning as the descriptor's own `deny_unknown_fields`: a limit
|
||||
// this build does not understand is one it would otherwise ignore while
|
||||
// reporting success.
|
||||
let r: Result<BootDescriptor, _> =
|
||||
serde_json::from_str(r#"{"video":{"bitrate_kbps":8000,"fps_cap":30}}"#);
|
||||
assert!(r.is_err(), "an unknown video limit must not be ignored");
|
||||
}
|
||||
}
|
||||
|
||||
+204
-10
@@ -13,9 +13,9 @@ pub fn encode_nescope_stats(buf: &mut Vec<u8>, game_fps: u8, frame_count: u32) {
|
||||
buf.extend_from_slice(&frame_count.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Hudless stats: capture FPS, encode time, dropped frames, diagnostics, capture latency.
|
||||
/// nescapture stats: capture FPS, encode time, dropped frames, diagnostics, capture latency.
|
||||
/// [1][capture_fps: u8][encode_avg_ms: f32 LE][dropped: u32 LE][present_attempts: u32 LE][capture_attempts: u32 LE][capture_ms: f32 LE]
|
||||
pub fn encode_hudless_stats(
|
||||
pub fn encode_nescapture_stats(
|
||||
buf: &mut Vec<u8>,
|
||||
capture_fps: u8,
|
||||
encode_avg_ms: f32,
|
||||
@@ -60,15 +60,98 @@ pub fn encode_hub_stats(
|
||||
buf.push(audio_channels);
|
||||
}
|
||||
|
||||
/// What the video bitrate is made of, and who chose it.
|
||||
///
|
||||
/// Appended to a hub stats packet rather than replacing anything, so an older
|
||||
/// reader keeps working on the part it understands -- `decode_stats` already
|
||||
/// guards each field on the length it needs.
|
||||
///
|
||||
/// **The split is the point.** One combined byte counter cannot distinguish an
|
||||
/// encoder ignoring its bitrate target from a stream that is mostly keyframes,
|
||||
/// and those have opposite fixes. A session overshooting its target by ten times
|
||||
/// looked identical either way, which is why the cause stayed ambiguous for
|
||||
/// weeks.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct VideoBreakdown {
|
||||
/// Keyframe bits per second, measured over the last second.
|
||||
pub key_bps: u32,
|
||||
/// Everything else, measured the same way.
|
||||
pub delta_bps: u32,
|
||||
/// Keyframes in the last second.
|
||||
pub keyframes: u8,
|
||||
/// What the controller is asking the encoder for.
|
||||
pub target_kbps: u32,
|
||||
/// The ceiling it is choosing within, after any client lowered it.
|
||||
pub ceiling_kbps: u32,
|
||||
/// How much the box's own pipeline delay *varied* over the last second, in
|
||||
/// milliseconds, at the median and the 95th percentile.
|
||||
///
|
||||
/// Variation rather than absolute cost, and necessarily so: the encoder
|
||||
/// stamps a frame with milliseconds since its own start, not since any
|
||||
/// epoch, so the difference to wall clock holds an unknown constant even
|
||||
/// though both run on the same machine. It is also the comparable
|
||||
/// quantity -- the client measures the same thing about the total arrival
|
||||
/// delay, with the same code, so the gap between the two is what the
|
||||
/// network added.
|
||||
///
|
||||
/// The point is attribution: a client seeing late frames cannot otherwise
|
||||
/// tell a stalled encoder from a jittery path, and buffering against the
|
||||
/// first is latency spent hiding a fault that should be fixed instead.
|
||||
pub pipeline_p50_ms: u16,
|
||||
pub pipeline_p95_ms: u16,
|
||||
/// The worst single frame in that second.
|
||||
pub pipeline_max_ms: u16,
|
||||
/// The ceiling the box itself was given, which no client may exceed.
|
||||
///
|
||||
/// Separate from `ceiling_kbps` because a client that lowers the ceiling
|
||||
/// would otherwise have nothing left to raise it against: the only number
|
||||
/// it can see is the one it just lowered. A control that can be turned down
|
||||
/// and not back up is worse than no control.
|
||||
pub box_ceiling_kbps: u32,
|
||||
/// Why the target is what it is; see the hub's control module.
|
||||
pub reason: u8,
|
||||
/// 0 when the controller is deciding, 1 when a person set it by hand.
|
||||
pub manual: u8,
|
||||
/// How far behind the send queue is, in milliseconds of video.
|
||||
///
|
||||
/// Bytes handed to the transport and not yet gone, over the rate they are
|
||||
/// leaving at. This is the number that was invisible during the session
|
||||
/// where every frame arrived, nothing was lost, and the picture was still
|
||||
/// eight seconds old: loss cannot show a queue, only its overflow. Anything
|
||||
/// but near-zero here means latency is being spent on backlog.
|
||||
pub backlog_ms: u16,
|
||||
}
|
||||
|
||||
/// `[4B key_bps][4B delta_bps][1B keyframes][4B target][4B ceiling][1B reason]
|
||||
/// [1B manual][4B box_ceiling][2B pipeline_p50][2B pipeline_p95][2B pipeline_max]
|
||||
/// [2B backlog_ms]`
|
||||
pub const VIDEO_BREAKDOWN_LEN: usize = 31;
|
||||
|
||||
pub fn encode_video_breakdown(buf: &mut Vec<u8>, b: &VideoBreakdown) {
|
||||
buf.reserve(VIDEO_BREAKDOWN_LEN);
|
||||
buf.extend_from_slice(&b.key_bps.to_le_bytes());
|
||||
buf.extend_from_slice(&b.delta_bps.to_le_bytes());
|
||||
buf.push(b.keyframes);
|
||||
buf.extend_from_slice(&b.target_kbps.to_le_bytes());
|
||||
buf.extend_from_slice(&b.ceiling_kbps.to_le_bytes());
|
||||
buf.push(b.reason);
|
||||
buf.push(b.manual);
|
||||
buf.extend_from_slice(&b.box_ceiling_kbps.to_le_bytes());
|
||||
buf.extend_from_slice(&b.pipeline_p50_ms.to_le_bytes());
|
||||
buf.extend_from_slice(&b.pipeline_p95_ms.to_le_bytes());
|
||||
buf.extend_from_slice(&b.pipeline_max_ms.to_le_bytes());
|
||||
buf.extend_from_slice(&b.backlog_ms.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Decoded stats from any source.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PipelineStats {
|
||||
pub nescope_fps: u8,
|
||||
pub nescope_frames: u32,
|
||||
pub hudless_fps: u8,
|
||||
pub hudless_encode_ms: f32,
|
||||
pub hudless_capture_ms: f32,
|
||||
pub hudless_dropped: u32,
|
||||
pub nescapture_fps: u8,
|
||||
pub nescapture_encode_ms: f32,
|
||||
pub nescapture_capture_ms: f32,
|
||||
pub nescapture_dropped: u32,
|
||||
pub hub_clients: u8,
|
||||
pub hub_video_mb: u32,
|
||||
pub hub_relay_ms: f32,
|
||||
@@ -76,6 +159,8 @@ pub struct PipelineStats {
|
||||
pub capture_attempts: u32,
|
||||
pub audio_bitrate_kbps: u32,
|
||||
pub audio_channels: u8,
|
||||
/// `None` from a hub that predates the breakdown.
|
||||
pub video: Option<VideoBreakdown>,
|
||||
}
|
||||
|
||||
/// Try to decode a single stats packet. The `msg_type` is the frame-level
|
||||
@@ -87,13 +172,13 @@ pub fn decode_stats(msg_type: u8, data: &[u8], stats: &mut PipelineStats) {
|
||||
stats.nescope_frames = u32::from_le_bytes([data[1], data[2], data[3], data[4]]);
|
||||
}
|
||||
STATS_HUDLESS if data.len() >= 17 => {
|
||||
stats.hudless_fps = data[0];
|
||||
stats.hudless_encode_ms = f32::from_le_bytes([data[1], data[2], data[3], data[4]]);
|
||||
stats.hudless_dropped = u32::from_le_bytes([data[5], data[6], data[7], data[8]]);
|
||||
stats.nescapture_fps = data[0];
|
||||
stats.nescapture_encode_ms = f32::from_le_bytes([data[1], data[2], data[3], data[4]]);
|
||||
stats.nescapture_dropped = u32::from_le_bytes([data[5], data[6], data[7], data[8]]);
|
||||
stats.present_attempts = u32::from_le_bytes([data[9], data[10], data[11], data[12]]);
|
||||
stats.capture_attempts = u32::from_le_bytes([data[13], data[14], data[15], data[16]]);
|
||||
if data.len() >= 21 {
|
||||
stats.hudless_capture_ms =
|
||||
stats.nescapture_capture_ms =
|
||||
f32::from_le_bytes([data[17], data[18], data[19], data[20]]);
|
||||
}
|
||||
}
|
||||
@@ -110,7 +195,116 @@ pub fn decode_stats(msg_type: u8, data: &[u8], stats: &mut PipelineStats) {
|
||||
if data.len() >= 14 {
|
||||
stats.audio_channels = data[13];
|
||||
}
|
||||
if data.len() >= 14 + VIDEO_BREAKDOWN_LEN {
|
||||
let d = &data[14..];
|
||||
let u32_at = |o: usize| u32::from_le_bytes(d[o..o + 4].try_into().unwrap());
|
||||
stats.video = Some(VideoBreakdown {
|
||||
key_bps: u32_at(0),
|
||||
delta_bps: u32_at(4),
|
||||
keyframes: d[8],
|
||||
target_kbps: u32_at(9),
|
||||
ceiling_kbps: u32_at(13),
|
||||
reason: d[17],
|
||||
manual: d[18],
|
||||
box_ceiling_kbps: u32_at(19),
|
||||
pipeline_p50_ms: u16::from_le_bytes([d[23], d[24]]),
|
||||
pipeline_p95_ms: u16::from_le_bytes([d[25], d[26]]),
|
||||
pipeline_max_ms: u16::from_le_bytes([d[27], d[28]]),
|
||||
backlog_ms: u16::from_le_bytes([d[29], d[30]]),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod breakdown_tests {
|
||||
use super::*;
|
||||
|
||||
fn breakdown() -> VideoBreakdown {
|
||||
VideoBreakdown {
|
||||
key_bps: 3_200_000,
|
||||
delta_bps: 6_800_000,
|
||||
keyframes: 2,
|
||||
target_kbps: 6_000,
|
||||
ceiling_kbps: 8_000,
|
||||
reason: 1,
|
||||
manual: 0,
|
||||
box_ceiling_kbps: 8_000,
|
||||
pipeline_p50_ms: 9,
|
||||
pipeline_p95_ms: 24,
|
||||
pipeline_max_ms: 61,
|
||||
backlog_ms: 40,
|
||||
}
|
||||
}
|
||||
|
||||
fn hub_packet(with_breakdown: bool) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
encode_hub_stats(&mut buf, 1, 10_000_000, 0.0, 128, 2);
|
||||
if with_breakdown {
|
||||
encode_video_breakdown(&mut buf, &breakdown());
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_breakdown_survives_the_wire() {
|
||||
let packet = hub_packet(true);
|
||||
let mut stats = PipelineStats::default();
|
||||
decode_stats(STATS_HUB, &packet[1..], &mut stats);
|
||||
assert_eq!(stats.video, Some(breakdown()));
|
||||
// The fields that were always there still read correctly beside it.
|
||||
assert_eq!(stats.hub_clients, 1);
|
||||
assert_eq!(stats.audio_bitrate_kbps, 128);
|
||||
assert_eq!(stats.audio_channels, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hub_without_the_breakdown_still_reads() {
|
||||
// The reason this is appended rather than folded into the layout: a hub
|
||||
// that predates it keeps working, and says so by omission rather than by
|
||||
// reporting zeroes that look like a stream carrying nothing.
|
||||
let packet = hub_packet(false);
|
||||
let mut stats = PipelineStats::default();
|
||||
decode_stats(STATS_HUB, &packet[1..], &mut stats);
|
||||
assert_eq!(stats.video, None);
|
||||
assert_eq!(stats.hub_clients, 1);
|
||||
assert_eq!(stats.audio_channels, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_breakdown_is_left_out_rather_than_half_read() {
|
||||
let full = hub_packet(true);
|
||||
for n in 15..full.len() - 1 {
|
||||
let mut stats = PipelineStats::default();
|
||||
decode_stats(STATS_HUB, &full[1..n], &mut stats);
|
||||
assert_eq!(stats.video, None, "{n} bytes produced a partial breakdown");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_split_distinguishes_the_two_ways_a_stream_overshoots() {
|
||||
// The whole reason for the split. Same total, opposite causes: an
|
||||
// encoder ignoring its target, and a stream that is nearly all
|
||||
// keyframes. One counter cannot tell them apart.
|
||||
let ignoring_target = VideoBreakdown {
|
||||
key_bps: 200_000,
|
||||
delta_bps: 9_800_000,
|
||||
keyframes: 1,
|
||||
..breakdown()
|
||||
};
|
||||
let keyframe_storm = VideoBreakdown {
|
||||
key_bps: 9_000_000,
|
||||
delta_bps: 1_000_000,
|
||||
keyframes: 30,
|
||||
..breakdown()
|
||||
};
|
||||
assert_eq!(
|
||||
ignoring_target.key_bps + ignoring_target.delta_bps,
|
||||
keyframe_storm.key_bps + keyframe_storm.delta_bps,
|
||||
);
|
||||
assert!(ignoring_target.delta_bps > ignoring_target.key_bps);
|
||||
assert!(keyframe_storm.key_bps > keyframe_storm.delta_bps);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user