diff --git a/crates/punktfunk-host/src/encode/libav.rs b/crates/punktfunk-host/src/encode/libav.rs index 12c5c7b0..7e786f48 100644 --- a/crates/punktfunk-host/src/encode/libav.rs +++ b/crates/punktfunk-host/src/encode/libav.rs @@ -8,7 +8,7 @@ use anyhow::{Context, Result}; use ffmpeg_next as ffmpeg; use ffmpeg_next::ffi; // = ffmpeg_sys_next use ffmpeg_next::format::Pixel; -use ffmpeg_next::{encoder, Packet}; +use ffmpeg_next::{encoder, Packet, Rational}; use std::os::raw::c_int; /// swscale: nearest-neighbour scaler flag (`SWS_POINT`). We never rescale (src dims == dst dims), so @@ -35,6 +35,36 @@ pub(crate) enum PollOutcome { 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 /// `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. diff --git a/crates/punktfunk-host/src/encode/linux/mod.rs b/crates/punktfunk-host/src/encode/linux/mod.rs index 792ab1f2..152ee532 100644 --- a/crates/punktfunk-host/src/encode/linux/mod.rs +++ b/crates/punktfunk-host/src/encode/linux/mod.rs @@ -16,12 +16,14 @@ use crate::capture::{CapturedFrame, FramePayload, PixelFormat}; use anyhow::{anyhow, bail, Context, Result}; use ffmpeg::format::Pixel; use ffmpeg::util::frame::Video as VideoFrame; -use ffmpeg::{codec, encoder, Dictionary, Rational}; +use ffmpeg::{codec, encoder, Dictionary}; use ffmpeg_next as ffmpeg; use std::os::raw::c_int; 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 /// 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_height(height); video.set_format(nvenc_pixel); // NVENC converts RGB→YUV internally - 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); - // 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); + // Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract. + apply_low_latency_rc(&mut video, fps, bitrate_bps); // 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 // "freeze". NVENC emits one IDR at stream start, then P-frames only; `forced-idr` (below) diff --git a/crates/punktfunk-host/src/encode/linux/vaapi.rs b/crates/punktfunk-host/src/encode/linux/vaapi.rs index 8775c830..19c7137e 100644 --- a/crates/punktfunk-host/src/encode/linux/vaapi.rs +++ b/crates/punktfunk-host/src/encode/linux/vaapi.rs @@ -26,7 +26,7 @@ use super::{Codec, EncodedFrame, Encoder}; use crate::capture::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat}; use anyhow::{anyhow, bail, Context, Result}; use ffmpeg::format::Pixel; -use ffmpeg::{codec, encoder, Dictionary, Rational}; +use ffmpeg::{codec, encoder, Dictionary}; use ffmpeg_next as ffmpeg; use std::ffi::{CStr, CString}; use std::os::fd::AsRawFd; @@ -34,7 +34,9 @@ use std::os::raw::c_int; use std::ptr; 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 /// `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_height(height); video.set_format(Pixel::NV12); // sw view; pix_fmt overridden to VAAPI below - 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); // == 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); + // Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract. + apply_low_latency_rc(&mut video, fps, bitrate_bps); 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) // 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 diff --git a/crates/punktfunk-host/src/encode/windows/ffmpeg_win.rs b/crates/punktfunk-host/src/encode/windows/ffmpeg_win.rs index 1c028ea7..265bb8c4 100644 --- a/crates/punktfunk-host/src/encode/windows/ffmpeg_win.rs +++ b/crates/punktfunk-host/src/encode/windows/ffmpeg_win.rs @@ -44,7 +44,7 @@ use super::{ChromaFormat, Codec, EncodedFrame, Encoder}; use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat}; use anyhow::{anyhow, bail, Context, Result}; use ffmpeg::format::Pixel; -use ffmpeg::{codec, encoder, Dictionary, Rational}; +use ffmpeg::{codec, encoder, Dictionary}; use ffmpeg_next as ffmpeg; use std::os::raw::{c_int, c_uint, c_void}; use std::ptr; @@ -61,7 +61,8 @@ use windows::Win32::Graphics::Dxgi::Common::{ }; 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 @@ -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 // D3D11/QSV below; libavcodec still uses this as `sw_pix_fmt`. video.set_format(Pixel::from(sw_pix_fmt)); - 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); // 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); + // Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract. + apply_low_latency_rc(&mut video, fps, bitrate_bps); 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) if ten_bit { // 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer. The client auto-detects PQ from