diff --git a/README.md b/README.md index 9076eaa..53916ac 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,10 @@ pulled in as a git dependency. Checked on Intel Meteor Lake (iHD 26.1.5) by decoding with ffmpeg: luma above 35 dB PSNR against the source, and RGB converted to BT.601 and BT.709 to within a few code values. These run as tests, for both codecs, wherever - ffmpeg and a VA-API device are both present. H.265 passes them on Intel - Arrow Lake (iHD 26.1.2). On AMD Radeon RX 9070 (radeonsi, Mesa 26.0.8) it - passes all but the color conversion and bitrate checks: chroma converted by - the video processor lands about 7 code values off, for either codec, and - radeonsi's rate control runs about 4x over 250 kbps on per-frame noise. H.264 encode on that card does not yet - produce a decodable stream. + ffmpeg and a VA-API device are both present. Both codecs pass them on Intel + Arrow Lake (iHD 26.3.5) and AMD Radeon RX 9070 (radeonsi, Mesa 26.2.5), + where the stream also holds within a quarter of its bitrate from 2 Mbps down + to 250 kbps. - **DMA-BUF import and video post-processing.** `dmabuf` wraps a DRM PRIME buffer as a surface without copying it; `vpp` blits one surface into another through `VAEntrypointVideoProc`, scaling, converting, and re-tiling on the diff --git a/src/codec/h264/synthesizer.rs b/src/codec/h264/synthesizer.rs index 697f2dc..90b4109 100644 --- a/src/codec/h264/synthesizer.rs +++ b/src/codec/h264/synthesizer.rs @@ -10,14 +10,12 @@ use crate::codec::h264::parser::HrdParams; use crate::codec::h264::parser::NaluType; use crate::codec::h264::parser::Pps; use crate::codec::h264::parser::SliceHeader; -#[cfg(feature = "vaapi")] use crate::codec::h264::parser::SliceType; use crate::codec::h264::parser::Sps; use crate::codec::h264::parser::DEFAULT_4X4_INTER; use crate::codec::h264::parser::DEFAULT_4X4_INTRA; use crate::codec::h264::parser::DEFAULT_8X8_INTER; use crate::codec::h264::parser::DEFAULT_8X8_INTRA; -#[cfg(feature = "vaapi")] use crate::encode::IsReference; mod private { @@ -435,10 +433,8 @@ impl<'n, W: Write> Synthesizer<'n, Pps, W> { } } -#[cfg(feature = "vaapi")] pub type NumTrailingBits = u8; -#[cfg(feature = "vaapi")] impl<'n, W: Write> Synthesizer<'n, SliceHeader, W> { pub fn synthesize( header: &'n SliceHeader, diff --git a/src/encode.rs b/src/encode.rs index fbb3be2..5eb7afd 100644 --- a/src/encode.rs +++ b/src/encode.rs @@ -27,11 +27,12 @@ //! the GPU. //! - [`Encoder::encode_nv12`] uploads CPU pixels. //! -//! Checked on Intel Meteor Lake with the iHD driver by decoding with ffmpeg's -//! software decoder: CPU and DMA-BUF input come back above 35 dB luma PSNR, -//! and BGRX converts to BT.601 and BT.709 with every channel within 6 code -//! values of the reference. The low-power entrypoint is untested, since that -//! device exposes the full one. +//! Checked on Intel Meteor Lake and Arrow Lake with the iHD driver, and on an +//! AMD RX 9070 with radeonsi, by decoding with ffmpeg's software decoder: CPU +//! and DMA-BUF input come back above 35 dB luma PSNR, and BGRX converts to +//! BT.601 and BT.709 with every channel within 6 code values of the reference. +//! The low-power entrypoint is untested, since those devices expose the full +//! one. use std::path::PathBuf; use std::rc::Rc; @@ -66,14 +67,15 @@ pub enum IsReference { LongTerm, } -/// Quantizer bounds carried over from the cros-codecs encoder. +/// Quantizer bounds for the rate controller. /// -/// The full H.264 range lets Intel's CBR controller spend too much of a GOP on -/// its IDR and then raise the P-frame quantizer enough to produce a visible -/// quality pulse. These bounds trade strict CBR at the extremes for stable -/// picture quality. +/// The floor keeps an IDR on easy content from taking a large share of the +/// rate. The ceiling is the codecs' own: iHD and radeonsi both honor it, so a +/// lower one sets a smallest picture, and a stream asked for less overshoots +/// its rate several times over. A lower ceiling does steady iHD's H.264 IDRs, +/// but under congestion missing the rate costs more than that. const MIN_QP: u8 = 18; -const MAX_QP: u8 = 36; +const MAX_QP: u8 = 51; /// Window over which libva's rate controller targets the configured bitrate. /// /// This matches cros-codecs. The old `framerate * 1000` value confused frames @@ -156,6 +158,8 @@ struct Reference { /// What one picture's parameter buffers are built from. struct PictureInfo<'a> { idr: bool, + /// How many IDR pictures came before this one. + idr_id: u32, /// How many pictures after the IDR this one comes. count: u32, /// The surface its reconstruction is written to. @@ -170,6 +174,8 @@ enum Syntax { H264 { sps: Rc, pps: Rc, + /// The packed header kinds the driver takes from us; it writes the rest. + packed: u32, }, H265 { stream: h265::Stream, @@ -191,6 +197,8 @@ pub struct Encoder { reference: Option<(usize, u32)>, counter: u32, + /// IDR pictures coded so far, so two in a row carry different H.264 `idr_pic_id`s. + idrs: u32, /// The video processor converting and scaling DMA-BUF input, opened on the /// first frame that needs it. @@ -244,10 +252,12 @@ impl Encoder { // buffering) and the level are ours. For H.264 that is the SPS and PPS; // iHD takes them without being told, and a driver that follows the spec // writes its own unless the config asks. For H.265 it is the VPS, SPS and - // PPS as one sequence header, plus each slice header, as ffmpeg packs - // them; the driver writes the slice headers if it does not take them. + // PPS as one sequence header. Each slice header is packed too, as ffmpeg + // packs them; the driver writes the slice headers if it does not take + // them. radeonsi needs them for H.264: it takes the slice's NAL header + // from the packed one, and drops the packed parameter sets without one. let wanted = match codec { - Codec::H264 => VA_ENC_PACKED_HEADER_SEQUENCE | VA_ENC_PACKED_HEADER_PICTURE, + Codec::H264 => VA_ENC_PACKED_HEADER_SEQUENCE | VA_ENC_PACKED_HEADER_PICTURE | VA_ENC_PACKED_HEADER_SLICE, Codec::H265 => VA_ENC_PACKED_HEADER_SEQUENCE | VA_ENC_PACKED_HEADER_SLICE, }; let packed = packed_headers(&display, codec, entrypoint) & wanted; @@ -271,7 +281,7 @@ impl Encoder { let syntax = match codec { Codec::H264 => { let (sps, pps) = build_sps_pps(&config); - Syntax::H264 { sps, pps } + Syntax::H264 { sps, pps, packed } } Codec::H265 => Syntax::H265 { stream: h265::Stream::new(&display, entrypoint, &mut va_config, &config)?, @@ -315,6 +325,7 @@ impl Encoder { recon, reference: None, counter: 0, + idrs: 0, processor: None, reset_rate_control: false, coded, @@ -477,6 +488,7 @@ impl Encoder { }; let info = PictureInfo { idr: is_idr, + idr_id: self.idrs, count: self.counter, recon: self.recon[recon_idx].id(), reference, @@ -484,13 +496,7 @@ impl Encoder { bitrate: self.config.bitrate, }; let buffers = match &self.syntax { - Syntax::H264 { sps, pps } => { - let mut buffers = h264_buffers(sps, pps, &info); - if is_idr { - buffers.extend(packed_parameter_sets(sps, pps)?); - } - buffers - } + Syntax::H264 { sps, pps, packed } => h264_buffers(sps, pps, &info, *packed)?, Syntax::H265 { stream, packed } => stream.buffers(&self.config, &info, *packed), }; @@ -537,6 +543,7 @@ impl Encoder { self.reference = Some((recon_idx, self.counter)); self.counter += 1; + self.idrs = self.idrs.wrapping_add(u32::from(is_idr)); self.reset_rate_control = false; Ok(bitstream) } @@ -751,8 +758,8 @@ fn build_sps_pps(config: &Config) -> (Rc, Rc) { (sps, pps) } -/// Returns the sequence, picture and slice parameters of one H.264 picture. -fn h264_buffers(sps: &Sps, pps: &Rc, picture: &PictureInfo<'_>) -> Vec { +/// Returns the parameter buffers of one H.264 picture, then the packed headers the driver takes: the SPS and PPS on an IDR, and the slice header. +fn h264_buffers(sps: &Sps, pps: &Rc, picture: &PictureInfo<'_>, packed: u32) -> anyhow::Result> { let meta = FrameMeta::new(picture.count); let slice_type = if picture.idr { SliceType::I } else { SliceType::P }; let header = SliceHeaderBuilder::new(pps) @@ -760,17 +767,39 @@ fn h264_buffers(sps: &Sps, pps: &Rc, picture: &PictureInfo<'_>) -> Vec::synthesize( + &header, + sps, + pps, + picture.idr, + IsReference::ShortTerm, + &mut nal, + true, + ) + .map_err(|e| anyhow::anyhow!("synthesize slice header: {e:?}"))?; + // The slice data follows the header's last bit, not its padded last byte. + let bits = nal.len() * 8 - usize::from(trailing); + buffers.extend(packed_header(EncPackedHeaderType::Slice, &nal, bits)); + } + Ok(buffers) } /// Returns the SPS and PPS as packed headers, which go out with every IDR. @@ -782,8 +811,8 @@ fn packed_parameter_sets(sps: &Sps, pps: &Pps) -> anyhow::Result<[BufferType; 4] Synthesizer::<'_, Pps, _>::synthesize(3, pps, &mut pps_nal, true) .map_err(|e| anyhow::anyhow!("synthesize PPS: {e:?}"))?; - let (sps_param, sps_data) = packed_header(EncPackedHeaderType::Sequence, &sps_nal); - let (pps_param, pps_data) = packed_header(EncPackedHeaderType::Picture, &pps_nal); + let [sps_param, sps_data] = packed_header(EncPackedHeaderType::Sequence, &sps_nal, sps_nal.len() * 8); + let [pps_param, pps_data] = packed_header(EncPackedHeaderType::Picture, &pps_nal, pps_nal.len() * 8); Ok([sps_param, sps_data, pps_param, pps_data]) } @@ -978,12 +1007,11 @@ fn build_enc_slice_param( ))) } -/// A packed-header parameter + data buffer pair for a synthesized NAL. -fn packed_header(kind: EncPackedHeaderType, data: &[u8]) -> (BufferType, BufferType) { - let param = - BufferType::EncPackedHeaderParameter(EncPackedHeaderParameter::new(kind, (data.len() * 8) as u32, true)); +/// Returns the packed-header parameter and data buffers for the first `bits` of `data`, a NAL unit that carries its own emulation prevention. +fn packed_header(kind: EncPackedHeaderType, data: &[u8], bits: usize) -> [BufferType; 2] { + let param = BufferType::EncPackedHeaderParameter(EncPackedHeaderParameter::new(kind, bits as u32, true)); let payload = BufferType::EncPackedHeaderData(data.to_vec()); - (param, payload) + [param, payload] } #[cfg(test)] @@ -1628,7 +1656,8 @@ mod tests { ] } - /// Lowering the bitrate mid-stream shrinks the pictures without an IDR. + /// Lowering the bitrate mid-stream shrinks the pictures without an IDR, and + /// the rate controller holds the stream near each target. #[test] fn a_bitrate_change_takes_effect_without_a_keyframe() { for codec in CODECS { @@ -1644,24 +1673,25 @@ mod tests { return; }; let (idr_type, _) = idr_types(codec); - // The moving block over light noise: enough detail that 2 Mbps is - // spent, not so much that the quantizer bound decides the size instead - // of the rate controller. - let mut state = 0x2545_f491_u32; - let mut frame = |step: u32| { - let mut frame = nv12_frame(WIDTH, HEIGHT, step); - for sample in &mut frame[..(WIDTH * HEIGHT) as usize] { - state ^= state << 13; - state ^= state >> 17; - state ^= state << 5; - *sample = sample.saturating_add((state >> 28) as u8); + // A zone plate whose phase moves every picture: detail motion + // compensation cannot predict, so 2 Mbps is spent, yet the drivers + // can code it at 250 kbps when the quantizer may go high enough. + let frame = |step: u32| { + let (w, h) = (WIDTH as usize, HEIGHT as usize); + let mut frame = vec![128u8; w * h + w * h.div_ceil(2)]; + for y in 0..h { + for x in 0..w { + let (dx, dy) = (x as f64 - w as f64 / 2.0, y as f64 - h as f64 / 2.0); + let phase = (dx * dx + dy * dy) * 0.002 + f64::from(step) * 0.5; + frame[y * w + x] = (128.0 + 100.0 * phase.cos()) as u8; + } } frame }; // The mean picture size over the second half of 60 frames, once the // rate controller has settled. // Also asserts that no picture after the first is an IDR. - let mut average = |encoder: &mut Encoder, from: u32| { + let average = |encoder: &mut Encoder, from: u32| { let sizes: Vec = (from..from + 60) .map(|step| { let unit = encoder.encode_nv12(&frame(step), false).expect("encode"); @@ -1677,12 +1707,24 @@ mod tests { sizes[30..].iter().sum::() / 30 }; + // A settled stream within a quarter above its target; a quantizer + // ceiling the rate controller cannot get past misses by double or more. + let within = |bytes: usize, bitrate: u32| { + let target = bitrate as usize / 8 / 30; + assert!( + bytes * 4 <= target * 5, + "{codec:?} at {bitrate} bps: {bytes} bytes per picture, the target is {target}" + ); + }; + let high = average(&mut encoder, 0); + within(high, 2_000_000); encoder.set_bitrate(250_000).expect("set the bitrate"); assert_eq!(encoder.config().bitrate, 250_000); assert!(encoder.set_bitrate(0).is_err(), "a zero bitrate is refused"); let low = average(&mut encoder, 60); eprintln!("{codec:?} mean picture at 2 Mbps: {high} bytes, at 250 kbps: {low} bytes"); + within(low, 250_000); assert!( low * 3 < high, "{codec:?}: the rate did not drop: {high} -> {low} bytes per picture" diff --git a/src/encode/h265.rs b/src/encode/h265.rs index 026048b..e7d0a08 100644 --- a/src/encode/h265.rs +++ b/src/encode/h265.rs @@ -250,11 +250,19 @@ impl Stream { self.slice_parameters(slice_type, reference.as_ref()), ]; if picture.idr && packed & VA_ENC_PACKED_HEADER_SEQUENCE != 0 { - buffers.extend(packed_header(EncPackedHeaderType::Sequence, &self.headers)); + buffers.extend(super::packed_header( + EncPackedHeaderType::Sequence, + &self.headers, + self.headers.len() * 8, + )); } if packed & VA_ENC_PACKED_HEADER_SLICE != 0 { let header = self.slice_header(nal_type, slice_type, picture.count); - buffers.extend(packed_header(EncPackedHeaderType::Slice, &header)); + buffers.extend(super::packed_header( + EncPackedHeaderType::Slice, + &header, + header.len() * 8, + )); } buffers } @@ -682,12 +690,6 @@ fn timing(w: &mut Rbsp, framerate: u32) { w.ue(0); // num_ticks_poc_diff_one_minus1 } -/// Returns the packed-header parameter and data buffers for `data`, which carries its own emulation prevention. -fn packed_header(kind: EncPackedHeaderType, data: &[u8]) -> [BufferType; 2] { - let (param, payload) = super::packed_header(kind, data); - [param, payload] -} - /// Returns `general_level_idc` for the lowest Main tier level whose picture size and sample rate cover a `coded` picture at `framerate`. /// /// Only those limits, as for H.264: the level's maximum bitrate is not diff --git a/src/vpp.rs b/src/vpp.rs index f97a7a4..587f922 100644 --- a/src/vpp.rs +++ b/src/vpp.rs @@ -45,6 +45,11 @@ use crate::{ VAEntrypoint, VAProfile, }; +/// ITU-T H.273 code points for an explicitly described side of a blit. +const PRIMARIES_BT709: u8 = 1; +const TRANSFER_BT709: u8 = 1; +const MATRIX_UNSPECIFIED: u8 = 2; + /// Checks that `display`'s driver offers video post-processing, which is what [`Processor::with_display`] needs of a render node. /// /// # Errors @@ -68,6 +73,8 @@ pub fn probe(display: &Display) -> anyhow::Result<()> { /// a stream, or of a few renditions, pays for each once. pub struct Processor { display: Arc, + /// How the driver reads a named color space; see [`Colors`]. + colors: Colors, /// The NV12 surfaces [`resize`](Self::resize) writes into, recycled per size. outputs: RefCell>, // Drop order: the contexts before the config they were created from. @@ -132,8 +139,14 @@ impl Processor { ) .map_err(|e| anyhow::anyhow!("create VPP config: {e:?}"))?; + // Mesa's VA frontend serves every Gallium driver, radeonsi included. + let colors = match display.query_vendor_string() { + Ok(vendor) if vendor.starts_with("Mesa Gallium driver") => Colors::Explicit, + _ => Colors::Standard, + }; Ok(Self { display, + colors, outputs: RefCell::new(HashMap::new()), contexts: RefCell::new(HashMap::new()), config, @@ -188,7 +201,8 @@ impl Processor { } let size = output.size(); let context = self.context(size)?; - let result = run_blit(&context, input, output, input_color, output_color); + let parameters = pipeline(input.id(), self.colors, input_color, output_color); + let result = run_blit(&context, output, parameters); if result.is_err() { // A picture that failed half way can leave the context mid-picture, // so the next blit of this size starts on a fresh one. @@ -303,18 +317,11 @@ impl Processor { } /// Runs one blit on `context` and waits for it to finish. -fn run_blit( +fn run_blit( context: &Rc, - input: &Surface, output: &Surface, - input_color: Option, - output_color: Option, -) -> anyhow::Result<()> -where - D: SurfaceMemoryDescriptor, - E: SurfaceMemoryDescriptor, -{ - let parameters = pipeline(input.id(), input_color, output_color); + parameters: ProcPipelineParameterBuffer, +) -> anyhow::Result<()> { let buffer = context .create_buffer(BufferType::ProcPipelineParameter(parameters)) .map_err(|e| anyhow::anyhow!("create VPP pipeline buffer: {e:?}"))?; @@ -333,22 +340,47 @@ where Ok(()) } +/// How a driver's video processor reads a named color space. +/// +/// A [`Color`] is a matrix and a range and nothing more, as every other +/// conversion in the stack is, so a blit has to change those two alone. A +/// named standard asks iHD for exactly that. Mesa takes the BT.601 standard to +/// mean SMPTE 170M primaries and transfer as well, and gamut-maps red and green +/// by up to 10 code values, so it is given each side explicitly with the same +/// primaries and transfer on both. iHD reads an explicit side by its primaries +/// alone, which is why the two cannot share one form. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Colors { + Standard, + Explicit, +} + /// Returns a pipeline parameter buffer describing a plain blit of `surface`. /// /// No regions (so the whole input stretched over the whole output), no /// filters, no references, no rotation or mirroring, and each side's color -/// standard and range as given, or the driver's default where not. +/// space and range as given, in the form `colors` names. A side left `None` is +/// the driver's default for its format. fn pipeline( surface: bindings::VASurfaceID, + colors: Colors, input_color: Option, output_color: Option, ) -> ProcPipelineParameterBuffer { - let side = |color: Option| match color { - Some(color) => ( + let named = input_color.is_some() || output_color.is_some(); + let side = |color: Option| match (colors, color) { + (Colors::Explicit, _) if named => { + let (range, matrix) = color.map_or((0, MATRIX_UNSPECIFIED), |color| (color.va_range(), color.vui().2)); + ( + bindings::_VAProcColorStandardType_VAProcColorStandardExplicit as u8, + ProcColorProperties::new(0, range, PRIMARIES_BT709, TRANSFER_BT709, matrix), + ) + } + (_, Some(color)) => ( color.va_standard(), ProcColorProperties::new(0, color.va_range(), 0, 0, 0), ), - None => (0, ProcColorProperties::default()), + (_, None) => (0, ProcColorProperties::default()), }; let (input_standard, input_properties) = side(input_color); let (output_standard, output_properties) = side(output_color);