refactor(host): hoist the shared low-latency RC contract into encode/libav.rs

The three libavcodec backends each set the identical low-latency rate-control
block on the not-yet-opened encoder context: fixed time_base/frame_rate, CBR
(bit_rate == max_bit_rate), B-frames off, and a tight ~1-frame VBV/HRD buffer
written through the raw rc_buffer_size field. Move it once into
apply_low_latency_rc(&mut video, fps, bitrate_bps), and let the long VBV
rationale (why the tight buffer prevents high-motion bursts from overflowing
the send queue) live in one place instead of only in the NVENC path.

Each backend keeps the two genuinely per-backend calls around it: set_format
(pixel format differs) before, and gop_size after (NVENC's infinite/intra-
refresh wave vs the VAAPI/AMF i32::MAX). No behavior change — the field writes
are independent, so the slightly different max_b_frames/rc_buffer_size ordering
across backends is irrelevant. Folding the raw rc_buffer_size write into the
helper also removes the NVENC path's separate unsafe block. Drops the now-unused
ffmpeg::Rational import from all three.

Linux check + clippy green (0/0, nvenc,vulkan-encode,pyrowave) on RTX 5070 Ti;
ffmpeg_win.rs is Windows-cfg, pending .173 compile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 13:23:28 +02:00
parent fd8a062e2c
commit 7099266594
4 changed files with 48 additions and 46 deletions
+31 -1
View File
@@ -8,7 +8,7 @@ use anyhow::{Context, Result};
use ffmpeg_next as ffmpeg; use ffmpeg_next as ffmpeg;
use ffmpeg_next::ffi; // = ffmpeg_sys_next use ffmpeg_next::ffi; // = ffmpeg_sys_next
use ffmpeg_next::format::Pixel; use ffmpeg_next::format::Pixel;
use ffmpeg_next::{encoder, Packet}; use ffmpeg_next::{encoder, Packet, Rational};
use std::os::raw::c_int; use std::os::raw::c_int;
/// swscale: nearest-neighbour scaler flag (`SWS_POINT`). We never rescale (src dims == dst dims), so /// swscale: nearest-neighbour scaler flag (`SWS_POINT`). We never rescale (src dims == dst dims), so
@@ -35,6 +35,36 @@ pub(crate) enum PollOutcome {
Eof, Eof,
} }
/// Apply the shared low-latency rate-control contract to a **not-yet-opened** encoder context: a
/// fixed frame rate, CBR (target == max bitrate), B-frames off, and a tight ~1-frame VBV/HRD buffer.
///
/// The VBV size bounds any single frame. Under CBR with no buffer set, libav's encoders use a loose
/// default VBV, so a high-motion P-frame can balloon to many times the average; those extra packets
/// overflow the bounded send queue + kernel socket buffer and get dropped, which the client sees as
/// framedrops/jitter (and, on the infinite-GOP path, as old/stale frames flashing until the next
/// RFI). A tight ~1-frame buffer makes the encoder hold frame size roughly constant and absorb motion
/// as a momentary QP (quality) dip instead — the trade we want. Default = 1 frame of bits
/// (bitrate/fps); `PUNKTFUNK_VBV_FRAMES` tunes it (larger = better motion quality, bigger bursts).
///
/// The caller still owns `set_format` (pixel format) and `gop_size` (GOP policy differs: NVENC's
/// infinite/intra-refresh wave vs the VAAPI/AMF `i32::MAX`), since those are backend-specific.
pub(crate) fn apply_low_latency_rc(video: &mut encoder::video::Video, fps: u32, bitrate_bps: u64) {
video.set_time_base(Rational(1, fps as i32));
video.set_frame_rate(Some(Rational(fps as i32, 1)));
video.set_bit_rate(bitrate_bps as usize);
video.set_max_bit_rate(bitrate_bps as usize);
video.set_max_b_frames(0);
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * crate::encode::vbv_frames_env())
.clamp(1.0, i32::MAX as f64);
// SAFETY: `video` wraps a freshly-allocated `AVCodecContext` we hold by value and have not opened
// yet; `as_mut_ptr()` returns that non-null, aligned, exclusively-owned context. Writing the plain
// `rc_buffer_size` int before `open_with` is the supported way to set a field ffmpeg-next exposes
// no setter for. Sole owner → no aliasing; synchronous in-bounds scalar write.
unsafe {
(*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32;
}
}
/// Drain the encoder for one packet (shared across the NVENC/VAAPI/AMF/QSV libav backends). The /// Drain the encoder for one packet (shared across the NVENC/VAAPI/AMF/QSV libav backends). The
/// `EncodedFrame`'s only allocation is the `to_vec()` of the bitstream — the same copy each backend /// `EncodedFrame`'s only allocation is the `to_vec()` of the bitstream — the same copy each backend
/// already made — so this stays off any per-frame `dyn`/`Box`/channel path. /// already made — so this stays off any per-frame `dyn`/`Box`/channel path.
+6 -25
View File
@@ -16,12 +16,14 @@ use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use ffmpeg::format::Pixel; use ffmpeg::format::Pixel;
use ffmpeg::util::frame::Video as VideoFrame; use ffmpeg::util::frame::Video as VideoFrame;
use ffmpeg::{codec, encoder, Dictionary, Rational}; use ffmpeg::{codec, encoder, Dictionary};
use ffmpeg_next as ffmpeg; use ffmpeg_next as ffmpeg;
use std::os::raw::c_int; use std::os::raw::c_int;
use std::ptr; use std::ptr;
use super::libav::{pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT}; use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
};
use ffmpeg::ffi; // = ffmpeg_sys_next use ffmpeg::ffi; // = ffmpeg_sys_next
/// The swscale *source* pixel format for a captured packed RGB/BGR layout (the real byte order, not /// The swscale *source* pixel format for a captured packed RGB/BGR layout (the real byte order, not
@@ -287,29 +289,8 @@ impl NvencEncoder {
video.set_width(width); video.set_width(width);
video.set_height(height); video.set_height(height);
video.set_format(nvenc_pixel); // NVENC converts RGB→YUV internally video.set_format(nvenc_pixel); // NVENC converts RGB→YUV internally
video.set_time_base(Rational(1, fps as i32)); // Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
video.set_frame_rate(Some(Rational(fps as i32, 1))); apply_low_latency_rc(&mut video, fps, bitrate_bps);
video.set_bit_rate(bitrate_bps as usize);
video.set_max_bit_rate(bitrate_bps as usize);
// VBV/HRD buffer — bound the SIZE of any single frame. Under CBR with no buffer set, NVENC
// uses a loose default VBV, so a high-motion P-frame is allowed to balloon to many times the
// average; those extra packets overflow the bounded send queue + kernel socket buffer and
// get dropped, which the client sees as framedrops/jitter (and, on the infinite-GOP path, as
// old/stale frames flashing until the next RFI). A tight ~1-frame buffer makes the encoder
// hold frame size roughly constant and absorb motion as a momentary QP (quality) dip instead
// — the trade we want. Default = 1 frame of bits (bitrate/fps); PUNKTFUNK_VBV_FRAMES tunes it
// (larger = better motion quality but bigger per-frame bursts).
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * crate::encode::vbv_frames_env())
.clamp(1.0, i32::MAX as f64);
// SAFETY: `video` is the ffmpeg-next encoder builder wrapping a freshly-allocated
// `AVCodecContext` that we hold by value and have not opened yet; `video.as_mut_ptr()` returns
// that non-null, properly-aligned, exclusively-owned context. Writing the plain `rc_buffer_size`
// int field before `open_with` is the supported way to set a field ffmpeg-next exposes no
// setter for. Sole owner → no aliasing; synchronous in-bounds scalar write.
unsafe {
(*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32;
}
video.set_max_b_frames(0);
// Infinite GOP — NO periodic IDR. A keyframe at 5120x1440 is ~20-40x a P-frame, so a // Infinite GOP — NO periodic IDR. A keyframe at 5120x1440 is ~20-40x a P-frame, so a
// periodic IDR is a recurring multi-millisecond encode+packetize+send spike — the ~2s // periodic IDR is a recurring multi-millisecond encode+packetize+send spike — the ~2s
// "freeze". NVENC emits one IDR at stream start, then P-frames only; `forced-idr` (below) // "freeze". NVENC emits one IDR at stream start, then P-frames only; `forced-idr` (below)
@@ -26,7 +26,7 @@ use super::{Codec, EncodedFrame, Encoder};
use crate::capture::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat}; use crate::capture::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use ffmpeg::format::Pixel; use ffmpeg::format::Pixel;
use ffmpeg::{codec, encoder, Dictionary, Rational}; use ffmpeg::{codec, encoder, Dictionary};
use ffmpeg_next as ffmpeg; use ffmpeg_next as ffmpeg;
use std::ffi::{CStr, CString}; use std::ffi::{CStr, CString};
use std::os::fd::AsRawFd; use std::os::fd::AsRawFd;
@@ -34,7 +34,9 @@ use std::os::raw::c_int;
use std::ptr; use std::ptr;
use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::atomic::{AtomicU8, Ordering};
use super::libav::{pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT}; use super::libav::{
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
};
use ffmpeg::ffi; // = ffmpeg_sys_next use ffmpeg::ffi; // = ffmpeg_sys_next
/// `fourcc(a,b,c,d)` — DRM FourCC packing (`a | b<<8 | c<<16 | d<<24`). /// `fourcc(a,b,c,d)` — DRM FourCC packing (`a | b<<8 | c<<16 | d<<24`).
@@ -182,15 +184,9 @@ unsafe fn open_vaapi_encoder_mode(
video.set_width(width); video.set_width(width);
video.set_height(height); video.set_height(height);
video.set_format(Pixel::NV12); // sw view; pix_fmt overridden to VAAPI below video.set_format(Pixel::NV12); // sw view; pix_fmt overridden to VAAPI below
video.set_time_base(Rational(1, fps as i32)); // Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
video.set_frame_rate(Some(Rational(fps as i32, 1))); apply_low_latency_rc(&mut video, fps, bitrate_bps);
video.set_bit_rate(bitrate_bps as usize);
video.set_max_bit_rate(bitrate_bps as usize); // == target → vaapi_encode picks CBR when supported
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * crate::encode::vbv_frames_env())
.clamp(1.0, i32::MAX as f64);
video.set_max_b_frames(0);
let raw = video.as_mut_ptr(); let raw = video.as_mut_ptr();
(*raw).rc_buffer_size = vbv_bits as i32;
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI) (*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
// We hand the encoder BT.709 *limited* NV12 (swscale CSC on the CPU path; scale_vaapi pinned // We hand the encoder BT.709 *limited* NV12 (swscale CSC on the CPU path; scale_vaapi pinned
// to `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range // to `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range
@@ -44,7 +44,7 @@ use super::{ChromaFormat, Codec, EncodedFrame, Encoder};
use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat}; use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use ffmpeg::format::Pixel; use ffmpeg::format::Pixel;
use ffmpeg::{codec, encoder, Dictionary, Rational}; use ffmpeg::{codec, encoder, Dictionary};
use ffmpeg_next as ffmpeg; use ffmpeg_next as ffmpeg;
use std::os::raw::{c_int, c_uint, c_void}; use std::os::raw::{c_int, c_uint, c_void};
use std::ptr; use std::ptr;
@@ -61,7 +61,8 @@ use windows::Win32::Graphics::Dxgi::Common::{
}; };
use super::libav::{ use super::libav::{
pixel_to_av, poll_encoder, PollOutcome, SWS_CS_BT2020, SWS_CS_ITU709, SWS_POINT, apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_BT2020, SWS_CS_ITU709,
SWS_POINT,
}; };
use ffmpeg::ffi; // = ffmpeg_sys_next use ffmpeg::ffi; // = ffmpeg_sys_next
@@ -179,15 +180,9 @@ unsafe fn open_win_encoder(
// Software view of the input layout (NV12 / P010). For the hw paths `pix_fmt` is overridden to // Software view of the input layout (NV12 / P010). For the hw paths `pix_fmt` is overridden to
// D3D11/QSV below; libavcodec still uses this as `sw_pix_fmt`. // D3D11/QSV below; libavcodec still uses this as `sw_pix_fmt`.
video.set_format(Pixel::from(sw_pix_fmt)); video.set_format(Pixel::from(sw_pix_fmt));
video.set_time_base(Rational(1, fps as i32)); // Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
video.set_frame_rate(Some(Rational(fps as i32, 1))); apply_low_latency_rc(&mut video, fps, bitrate_bps);
video.set_bit_rate(bitrate_bps as usize);
video.set_max_bit_rate(bitrate_bps as usize); // target == max → CBR
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * crate::encode::vbv_frames_env())
.clamp(1.0, i32::MAX as f64);
video.set_max_b_frames(0);
let raw = video.as_mut_ptr(); let raw = video.as_mut_ptr();
(*raw).rc_buffer_size = vbv_bits as i32;
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI) (*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
if ten_bit { if ten_bit {
// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer. The client auto-detects PQ from // 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer. The client auto-detects PQ from