fix(pf-encode/pyrowave): the bitrate pin holds on the WIRE, not the raw bitstream
A datagram-aligned PyroWave session inflates the codec bitstream ×1.2–1.3 on its way to the wire — greedy packing of few-hundred-byte atomic block packets into 1408 B windows zero-pads most window tails, plus the 4-byte prefixes and FRAG chains. The 2026-07 field report's 1440p60 10-bit "Automatic" pin of 407 Mb/s put a measured 550 Mb/s on a 1 GbE link; nothing enforced the pin past the rate controller. New shared WireBudget (pyrowave_wire.rs, both backends): tracks the real per-frame AU/bitstream ratio as a ×1024 fixed-point EMA (prior ×1.25, weight 1/8, clamped ×1.0–×2.0) and deflates the budget handed to pyrowave's rate control by it, so the windowed AU lands on the configured rate. Sealed-datagram framing (+4.5%) and FEC parity stay uncompensated — H.26x sessions carry those on top of the configured bitrate too, and the pin must mean the same thing for every codec. Dense (non-chunked) sessions are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -395,6 +395,9 @@ pub struct PyroWaveEncoder {
|
||||
/// packet to it, so each wire shard carries whole self-delimiting packets. `None` =
|
||||
/// one packet per AU (the dense MVP shape).
|
||||
wire_chunk: Option<usize>,
|
||||
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
|
||||
/// WIRE, not just the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
|
||||
wire_budget: crate::pyrowave_wire::WireBudget,
|
||||
bitstream: Vec<u8>,
|
||||
pending: VecDeque<EncodedFrame>,
|
||||
frame_count: u64,
|
||||
@@ -653,6 +656,7 @@ impl PyroWaveEncoder {
|
||||
chroma444,
|
||||
frame_budget: budget_for(bitrate, fps),
|
||||
wire_chunk: None,
|
||||
wire_budget: crate::pyrowave_wire::WireBudget::new(),
|
||||
bitstream: Vec::new(),
|
||||
pending: VecDeque::new(),
|
||||
frame_count: 0,
|
||||
@@ -1120,6 +1124,16 @@ impl PyroWaveEncoder {
|
||||
Ok(self.cpu_img.unwrap().2)
|
||||
}
|
||||
|
||||
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
|
||||
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
|
||||
/// a promise about the wire, not the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
|
||||
fn rate_budget(&self) -> usize {
|
||||
match self.wire_chunk {
|
||||
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
|
||||
None => self.frame_budget,
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command
|
||||
/// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`.
|
||||
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
|
||||
@@ -1175,6 +1189,8 @@ impl PyroWaveEncoder {
|
||||
// paths propagate untouched and the recovery (`reset()`/`Drop`) `device_wait_idle()`s
|
||||
// before anything touches `cmd`; a buffer that completed its one-time submit is INVALID,
|
||||
// which the next `begin` may implicitly reset.
|
||||
// Resolved before the closure (which borrows `self` mutably for the recording calls).
|
||||
let rate_budget = self.rate_budget();
|
||||
let record_and_submit = (|| -> Result<()> {
|
||||
dev.begin_command_buffer(
|
||||
self.cmd,
|
||||
@@ -1396,7 +1412,7 @@ impl PyroWaveEncoder {
|
||||
],
|
||||
};
|
||||
let rc = pw::pyrowave_rate_control {
|
||||
maximum_bitstream_size: self.frame_budget,
|
||||
maximum_bitstream_size: rate_budget,
|
||||
};
|
||||
pw::pyrowave_device_set_command_buffer(
|
||||
self.pw_dev,
|
||||
@@ -1476,6 +1492,10 @@ impl PyroWaveEncoder {
|
||||
// single packet, or the datagram-aligned windowed AU (§4.4).
|
||||
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
|
||||
let au = crate::pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
|
||||
if self.wire_chunk.is_some() {
|
||||
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
|
||||
self.wire_budget.observe(raw, au.len());
|
||||
}
|
||||
self.frame_count += 1;
|
||||
self.pending.push_back(EncodedFrame {
|
||||
data: au,
|
||||
|
||||
@@ -81,6 +81,61 @@ pub(crate) fn block_count_32x32(width: u32, height: u32, chroma444: bool) -> u32
|
||||
count
|
||||
}
|
||||
|
||||
/// Wire-aware deflation of the per-frame rate budget for the datagram-aligned mode.
|
||||
///
|
||||
/// [`build_au`]'s windowing inflates the codec bitstream on its way to the wire: greedy packing
|
||||
/// of pyrowave's few-hundred-byte atomic block packets into `chunk`-sized windows leaves the
|
||||
/// tail of most windows zero-padded, plus the 4-byte prefixes and FRAG-chain tails. At
|
||||
/// 1440p/~850 KiB frames that is ×1.2–1.3 — the 2026-07 field report's "Automatic" 407 Mb/s
|
||||
/// pin put 550 Mb/s on a 1 GbE link. The pin is a promise about the LINK, so the codec budget
|
||||
/// must absorb the framing: this tracker measures the real AU/bitstream ratio per frame and
|
||||
/// deflates the budget handed to pyrowave's rate control by its EMA. Sealed-datagram framing
|
||||
/// (packet header + AEAD tag) and FEC parity are deliberately NOT compensated — H.26x sessions
|
||||
/// carry those on top of the configured bitrate too, and the pin must mean the same thing for
|
||||
/// every codec.
|
||||
pub(crate) struct WireBudget {
|
||||
/// EMA of `built AU bytes / packetized bitstream bytes`, ×1024 fixed point.
|
||||
scale_x1024: u32,
|
||||
}
|
||||
|
||||
impl WireBudget {
|
||||
/// Startup prior (×1024 ≈ 1.25 — the 1440p field measurement's midpoint); the EMA
|
||||
/// converges onto the session's real ratio within ~a second of frames.
|
||||
const PRIOR_X1024: u32 = 1280;
|
||||
/// EMA weight 1/8: content-driven per-frame wobble smooths out; a mode/bitrate change
|
||||
/// re-converges in ~16 frames.
|
||||
const EMA_SHIFT: u32 = 3;
|
||||
/// Sanity clamp on the applied scale: never inflate the budget (×1.0 floor), never
|
||||
/// deflate below half (×2.0 cap — tiny explicit bitrates window very coarsely).
|
||||
const MIN_X1024: u32 = 1024;
|
||||
const MAX_X1024: u32 = 2048;
|
||||
|
||||
pub(crate) fn new() -> WireBudget {
|
||||
WireBudget {
|
||||
scale_x1024: Self::PRIOR_X1024,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one frame's measured inflation (`bitstream_len` = the packetized codec bytes the
|
||||
/// rate controller budgeted; `au_len` = the windowed AU that actually reaches the wire).
|
||||
pub(crate) fn observe(&mut self, bitstream_len: usize, au_len: usize) {
|
||||
if bitstream_len == 0 {
|
||||
return;
|
||||
}
|
||||
let sample = ((au_len as u64 * 1024) / bitstream_len as u64)
|
||||
.clamp(Self::MIN_X1024 as u64, Self::MAX_X1024 as u64) as u32;
|
||||
let ema = self.scale_x1024 as i64;
|
||||
self.scale_x1024 = (ema + ((sample as i64 - ema) >> Self::EMA_SHIFT)) as u32;
|
||||
}
|
||||
|
||||
/// The rate-control budget that makes the WIRE hit `budget` bytes/frame under the
|
||||
/// currently-measured inflation.
|
||||
pub(crate) fn deflate(&self, budget: usize) -> usize {
|
||||
let scale = self.scale_x1024.clamp(Self::MIN_X1024, Self::MAX_X1024) as u64;
|
||||
((budget as u64 * 1024) / scale) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame pyrowave's `packets` (each an `(offset, size)` into `bitstream`) into the wire AU.
|
||||
/// `wire_chunk = None` copies the single dense packet; `Some(chunk)` produces the windowed
|
||||
/// datagram-aligned AU (a whole number of `chunk`-sized windows).
|
||||
@@ -259,6 +314,37 @@ mod tests {
|
||||
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
|
||||
}
|
||||
|
||||
/// The wire-budget tracker: converges its EMA onto the measured AU/bitstream inflation,
|
||||
/// deflates the budget by exactly that ratio, and clamps runaway samples.
|
||||
#[test]
|
||||
fn wire_budget_converges_and_deflates() {
|
||||
let mut wb = WireBudget::new();
|
||||
// Prior ≈ ×1.25 (1280/1024): the first deflation is already conservative.
|
||||
assert_eq!(wb.deflate(1_024_000), 819_200);
|
||||
// Feed a steady ×1.30 inflation; the EMA must converge onto it.
|
||||
for _ in 0..64 {
|
||||
wb.observe(1000, 1300);
|
||||
}
|
||||
let b = wb.deflate(1_024_000);
|
||||
let expect = 1_024_000_u64 * 1000 / 1300;
|
||||
assert!(
|
||||
(b as i64 - expect as i64).unsigned_abs() < 8_000,
|
||||
"budget {b} should approach {expect}"
|
||||
);
|
||||
// A dense-ish run (×1.0) walks it back down to no deflation.
|
||||
for _ in 0..64 {
|
||||
wb.observe(1000, 1000);
|
||||
}
|
||||
assert_eq!(wb.deflate(1_024_000), 1_024_000);
|
||||
// Garbage samples are clamped: an absurd ratio can at most halve the budget…
|
||||
for _ in 0..256 {
|
||||
wb.observe(10, 1000);
|
||||
}
|
||||
assert!(wb.deflate(1_024_000) >= 512_000);
|
||||
// …and a zero-length observation is ignored, never a division by zero.
|
||||
wb.observe(0, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_color_bits_sets_range_and_hdr_bits() {
|
||||
let mut bs = vec![0u8; 16];
|
||||
|
||||
@@ -159,6 +159,9 @@ pub struct PyroWaveEncoder {
|
||||
frame_budget: usize,
|
||||
/// Datagram-aligned mode (plan §4.4): packetize at this boundary. `None` = one dense packet/AU.
|
||||
wire_chunk: Option<usize>,
|
||||
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
|
||||
/// WIRE, not just the raw bitstream (see [`pyrowave_wire::WireBudget`]).
|
||||
wire_budget: pyrowave_wire::WireBudget,
|
||||
bitstream: Vec<u8>,
|
||||
pending: VecDeque<EncodedFrame>,
|
||||
}
|
||||
@@ -288,6 +291,7 @@ impl PyroWaveEncoder {
|
||||
hdr16,
|
||||
frame_budget,
|
||||
wire_chunk: None,
|
||||
wire_budget: pyrowave_wire::WireBudget::new(),
|
||||
bitstream: Vec::new(),
|
||||
pending: VecDeque::new(),
|
||||
})
|
||||
@@ -441,6 +445,16 @@ impl PyroWaveEncoder {
|
||||
///
|
||||
/// # Safety
|
||||
/// Runs on the single encode thread; all pyrowave calls take handles this struct owns.
|
||||
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
|
||||
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
|
||||
/// a promise about the wire, not the raw bitstream (see [`pyrowave_wire::WireBudget`]).
|
||||
fn rate_budget(&self) -> usize {
|
||||
match self.wire_chunk {
|
||||
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
|
||||
None => self.frame_budget,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
|
||||
// A failed `reset()` leaves the encoder destroyed and null — fail cleanly rather than
|
||||
// handing null to pyrowave (see the Linux twin).
|
||||
@@ -616,7 +630,7 @@ impl PyroWaveEncoder {
|
||||
sync: std::mem::zeroed(),
|
||||
};
|
||||
let rc = pw::pyrowave_rate_control {
|
||||
maximum_bitstream_size: self.frame_budget,
|
||||
maximum_bitstream_size: self.rate_budget(),
|
||||
};
|
||||
pw_check(
|
||||
pw::pyrowave_encoder_encode_gpu_synchronous(
|
||||
@@ -664,6 +678,10 @@ impl PyroWaveEncoder {
|
||||
}
|
||||
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
|
||||
let au = pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
|
||||
if self.wire_chunk.is_some() {
|
||||
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
|
||||
self.wire_budget.observe(raw, au.len());
|
||||
}
|
||||
self.pending.push_back(EncodedFrame {
|
||||
data: au,
|
||||
pts_ns: frame.pts_ns,
|
||||
|
||||
Reference in New Issue
Block a user