From 232b6d6be2de236e9a3dff5e288ad14b78714d97 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 26 Jul 2026 10:29:30 +0200 Subject: [PATCH] test(encode/vaapi): extract the open-time decision logic and pin it with unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback backend under Vulkan Video — and the only AMD/Intel H.264 and 10-bit/HDR encoder — had 1,300 lines, 26 unsafe blocks, and zero tests. The device-free decisions now live in named functions with their contracts pinned: the entrypoint ladder + LP_MODE latch round-trip (the cross-GPU session-killer and the 8-bit-pins-10-bit under-advertisement are both key'd tests now), the PUNKTFUNK_VAAPI_LOW_POWER / _ASYNC_DEPTH grammars, the VUI ↔ scale_vaapi colour agreement (the Mesa-BT.601 hue-shift pin), the honest-downgrade depth table, the HEVC-Main10-only explicit profile, and the 10-bit probe gate. Probe + CPU-path encode round-trip ride along as #[ignore]d hardware smokes in the house style. No FFI plumbing was chased; no behavior changes. Co-Authored-By: Claude Fable 5 --- crates/pf-encode/src/enc/linux/vaapi.rs | 483 ++++++++++++++++++++---- 1 file changed, 412 insertions(+), 71 deletions(-) diff --git a/crates/pf-encode/src/enc/linux/vaapi.rs b/crates/pf-encode/src/enc/linux/vaapi.rs index c2836f84..fc9e2f44 100644 --- a/crates/pf-encode/src/enc/linux/vaapi.rs +++ b/crates/pf-encode/src/enc/linux/vaapi.rs @@ -94,23 +94,152 @@ type LpKey = (String, &'static str, bool); /// The [`LP_MODE`] key for this device/codec/depth. `render_node()` is re-read rather than cached /// so a GPU-preference change is picked up on the next open. fn lp_key(codec: Codec, ten_bit: bool) -> LpKey { - ( - render_node().to_string_lossy().into_owned(), - codec.label(), - ten_bit, - ) + lp_key_for(&render_node().to_string_lossy(), codec, ten_bit) +} + +/// [`lp_key`] with the render node explicit (device-free for the unit tests). Every part of the +/// key is load-bearing — see [`LP_MODE`] for the two field-motivated bugs (node: cross-GPU +/// session-killer; depth: HDR under-advertisement). +fn lp_key_for(node: &str, codec: Codec, ten_bit: bool) -> LpKey { + (node.to_owned(), codec.label(), ten_bit) } /// `PUNKTFUNK_VAAPI_LOW_POWER` pins the entrypoint mode (`1` = low-power only, `0` = full-feature /// only); unset → try full-feature first, fall back to low-power. fn low_power_override() -> Option { - match std::env::var("PUNKTFUNK_VAAPI_LOW_POWER").ok()?.trim() { + parse_low_power(&std::env::var("PUNKTFUNK_VAAPI_LOW_POWER").ok()?) +} + +/// [`low_power_override`]'s value grammar (device-free for the unit tests). Anything outside the +/// two literal sets means "no pin" — the `[full-feature, low-power]` ladder runs. +fn parse_low_power(raw: &str) -> Option { + match raw.trim() { "1" | "true" | "yes" | "on" => Some(true), "0" | "false" | "no" | "off" => Some(false), _ => None, } } +/// The entrypoint modes to attempt, in order (`false` = full-feature `EncSlice`, `true` = +/// low-power VDEnc): an operator pin tries exactly that one; a cached resolution ([`LP_MODE`]: +/// 1 = full-feature, 2 = low-power) skips the known-failing attempt; anything else runs the full +/// ladder — full-feature first so AMD's first-try open stays byte-for-byte unchanged. Never +/// empty: [`open_vaapi_encoder`] relies on at least one attempt running. +fn entrypoint_ladder(pin: Option, cached: u8) -> &'static [bool] { + match pin { + Some(true) => &[true], + Some(false) => &[false], + None => match cached { + 1 => &[false], + 2 => &[true], + _ => &[false, true], + }, + } +} + +/// The [`LP_MODE`] value a successful open latches (1 = full-feature, 2 = low-power). Paired with +/// [`entrypoint_ladder`], which maps it back to the single-mode retry list. +fn latched_mode(low_power: bool) -> u8 { + if low_power { + 2 + } else { + 1 + } +} + +/// The VUI colour metadata the encoder signals for the session's depth. +struct Vui { + colorspace: ffi::AVColorSpace, + range: ffi::AVColorRange, + primaries: ffi::AVColorPrimaries, + trc: ffi::AVColorTransferCharacteristic, +} + +/// 10-bit HDR: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010 the +/// CSC produces (swscale BT.2020 on the CPU path; `scale_vaapi` pinned to bt2020 on the zero-copy +/// path); the client decoder auto-detects PQ from the VUI. SDR: 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 RGB input +/// tagged), so signal that VUI — else the client decoder washes the picture out. +fn vui_for(ten_bit: bool) -> Vui { + if ten_bit { + Vui { + colorspace: ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL, + range: ffi::AVColorRange::AVCOL_RANGE_MPEG, + primaries: ffi::AVColorPrimaries::AVCOL_PRI_BT2020, + trc: ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084, + } + } else { + Vui { + colorspace: ffi::AVColorSpace::AVCOL_SPC_BT709, + range: ffi::AVColorRange::AVCOL_RANGE_MPEG, + primaries: ffi::AVColorPrimaries::AVCOL_PRI_BT709, + trc: ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709, + } + } +} + +/// The explicit `profile` option for this codec/depth, or `None` to let the encoder derive it. +/// HEVC Main10 is pinned explicitly so the depth is never silently dropped (`hevc_vaapi` would +/// derive it from the P010 surfaces, but a derivation can regress quietly); 10-bit AV1 is +/// input-driven — no profile knob — and every 8-bit open keeps the encoder default. +fn explicit_profile(codec: Codec, ten_bit: bool) -> Option<&'static str> { + (ten_bit && codec == Codec::H265).then_some("main10") +} + +/// `PUNKTFUNK_VAAPI_ASYNC_DEPTH` grammar: 1..=8 accepted verbatim; unset, junk, and out-of-range +/// values all resolve to depth 1 — the lowest-latency structure (see the `async_depth` note at the +/// call site for why 1 is the default and what depth ≥ 2 trades). +fn async_depth(raw: Option<&str>) -> u32 { + raw.and_then(|s| s.parse::().ok()) + .filter(|d| (1..=8).contains(d)) + .unwrap_or(1) +} + +/// The `scale_vaapi` filter args for the session's depth. The VPP's output colour is pinned to +/// exactly what the encoder's VUI signals ([`vui_for`]) — without the explicit options the +/// conversion matrix is whatever the driver defaults to for an unspecified output (Mesa: BT.601), +/// a hue shift against the signaled VUI. (The PQ transfer is per-channel and rides through the +/// matrix untouched.) +fn scale_vaapi_args(ten_bit: bool) -> &'static CStr { + if ten_bit { + c"format=p010:out_color_matrix=bt2020:out_range=limited" + } else { + c"format=nv12:out_color_matrix=bt709:out_range=limited" + } +} + +/// What a (captured format, negotiated bit depth) pair resolves to at open. 10-bit rides on the +/// captured PIXELS, not the negotiated depth — see [`crate::ten_bit_input`] for the failure the +/// reverse shape produces. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DepthResolution { + /// PQ-graded 10-bit frames on a non-10-bit session: refuse the open, so PQ content is never + /// mislabeled BT.709. + RefuseMislabeledPq, + /// 10-bit negotiated but the capture stayed SDR: honestly encode 8-bit (with a warning). + SdrDowngrade, + /// Format and negotiated depth agree. + Agreed, +} + +/// See [`DepthResolution`]. +fn resolve_depth(format: PixelFormat, bit_depth: u8) -> DepthResolution { + if format.is_hdr_rgb10() && bit_depth != 10 { + DepthResolution::RefuseMislabeledPq + } else if bit_depth == 10 && !format.is_hdr_rgb10() { + DepthResolution::SdrDowngrade + } else { + DepthResolution::Agreed + } +} + +/// Whether a codec is even eligible for the 10-bit probe: PyroWave answers 10-bit support on its +/// own path (no VAAPI involved), and a codec without a 10-bit profile can never pass. +fn ten_bit_probe_eligible(codec: Codec) -> bool { + codec.supports_10bit() && codec != Codec::PyroWave +} + /// Open the VAAPI encoder, resolving the entrypoint mode: try the full-feature entrypoint first /// and, if the driver rejects it, retry with `low_power=1` — modern Intel (Gen12+/Arc) exposes /// ONLY the low-power VDEnc entrypoint (ffmpeg's `vaapi_encode` defaults `low_power=0` and errors @@ -135,15 +264,7 @@ unsafe fn open_vaapi_encoder( .lock() .map(|m| m.get(&key).copied().unwrap_or(0)) .unwrap_or(0); - let modes: &[bool] = match low_power_override() { - Some(true) => &[true], - Some(false) => &[false], - None => match cached { - 1 => &[false], - 2 => &[true], - _ => &[false, true], - }, - }; + let modes: &[bool] = entrypoint_ladder(low_power_override(), cached); let mut first_err = None; for &lp in modes { match open_vaapi_encoder_mode( @@ -159,7 +280,7 @@ unsafe fn open_vaapi_encoder( ) { Ok(enc) => { if let Ok(mut m) = LP_MODE.get_or_init(|| Mutex::new(HashMap::new())).lock() { - m.insert(key.clone(), if lp { 2 } else { 1 }); + m.insert(key.clone(), latched_mode(lp)); } if lp { tracing::info!( @@ -216,32 +337,18 @@ unsafe fn open_vaapi_encoder_mode( apply_low_latency_rc(&mut video, fps, bitrate_bps); let raw = video.as_mut_ptr(); (*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI) - if ten_bit { - // HDR10: BT.2020 primaries + SMPTE-2084 (PQ) transfer, limited range — matches the P010 - // the CSC produces (swscale BT.2020 on the CPU path; scale_vaapi pinned to bt2020 on the - // zero-copy path). The client decoder auto-detects PQ from the VUI. - (*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL; - (*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG; - (*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020; - (*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084; - } else { - // 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 - // RGB input tagged), so signal that VUI — else the client decoder washes the picture out. - (*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709; - (*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG; - (*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709; - (*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709; - } + let vui = vui_for(ten_bit); + (*raw).colorspace = vui.colorspace; + (*raw).color_range = vui.range; + (*raw).color_primaries = vui.primaries; + (*raw).color_trc = vui.trc; (*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI; (*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref); (*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref); let mut opts = Dictionary::new(); - if ten_bit && codec == Codec::H265 { - // HEVC Main10. `hevc_vaapi` derives it from the P010 surfaces, but pin it explicitly so - // the depth is never silently dropped. (10-bit AV1 is input-driven — no profile knob.) - opts.set("profile", "main10"); + if let Some(profile) = explicit_profile(codec, ten_bit) { + opts.set("profile", profile); } // async_depth=1: `send_frame` blocks until THIS frame's ASIC encode completes — the lowest // latency structure libavcodec's vaapi_encode offers. Measured on the 780M at 1440p60: depth 1 @@ -251,11 +358,7 @@ unsafe fn open_vaapi_encoder_mode( // where depth 2 restores throughput at that one-frame cost. NOTE: the per-frame block tracks // GPU CLOCKS — a paced 60 fps trickle lets the VCN downclock (~8 ms/frame vs ~4.4 ms hot); // see `gpuclocks` for the session clock pin that removes the ramp tax. - let depth = std::env::var("PUNKTFUNK_VAAPI_ASYNC_DEPTH") - .ok() - .and_then(|s| s.parse::().ok()) - .filter(|d| (1..=8).contains(d)) - .unwrap_or(1); + let depth = async_depth(std::env::var("PUNKTFUNK_VAAPI_ASYNC_DEPTH").ok().as_deref()); opts.set("async_depth", &depth.to_string()); if low_power { opts.set("low_power", "1"); // VDEnc — the only encode entrypoint on modern Intel @@ -309,7 +412,7 @@ pub fn probe_can_encode(codec: Codec) -> bool { /// ([`crate::can_encode_10bit`]), so a non-Main10 GPU resolves every session to 8-bit SDR before /// the Welcome (honest downgrade). pub fn probe_can_encode_10bit(codec: Codec) -> bool { - if !codec.supports_10bit() || codec == Codec::PyroWave { + if !ten_bit_probe_eligible(codec) { return false; } if ffmpeg::init().is_err() { @@ -834,24 +937,7 @@ impl DmabufInner { } init!(src, ptr::null(), "buffer"); init!(hwmap, c"mode=read".as_ptr(), "hwmap"); - // Pin the VPP's output colour to what the encoder's VUI signals (BT.709 limited SDR, - // or BT.2020 limited P010 for HDR — the PQ transfer is per-channel and rides through - // the matrix untouched). Without the explicit options the conversion matrix is - // whatever the driver defaults to for an unspecified output (Mesa: BT.601) — a hue - // shift against the signaled VUI. - if ten_bit { - init!( - scale, - c"format=p010:out_color_matrix=bt2020:out_range=limited".as_ptr(), - "scale_vaapi" - ); - } else { - init!( - scale, - c"format=nv12:out_color_matrix=bt709:out_range=limited".as_ptr(), - "scale_vaapi" - ); - } + init!(scale, scale_vaapi_args(ten_bit).as_ptr(), "scale_vaapi"); init!(sink, ptr::null(), "buffersink"); let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int { @@ -1143,21 +1229,18 @@ impl VaapiEncoder { chroma: super::ChromaFormat, ) -> Result { // 10-bit rides on the captured format: an HDR capture (X2RGB10/X2BGR10) opens the P010 / - // Main10 / PQ-VUI variant of whichever inner path the first frame selects. A 10-bit - // request whose capture stayed SDR honestly encodes 8-bit; the reverse (PQ frames on an - // 8-bit session) is refused so PQ content is never mislabeled BT.709. - if format.is_hdr_rgb10() && bit_depth != 10 { - bail!( + // Main10 / PQ-VUI variant of whichever inner path the first frame selects. + match resolve_depth(format, bit_depth) { + DepthResolution::RefuseMislabeledPq => bail!( "captured 10-bit HDR frames ({format:?}) on an {bit_depth}-bit VAAPI session — \ refusing to mislabel PQ content" - ); - } - if bit_depth == 10 && !format.is_hdr_rgb10() { - tracing::warn!( + ), + DepthResolution::SdrDowngrade => tracing::warn!( bit_depth, ?format, "10-bit requested but the capture stayed SDR — encoding 8-bit" - ); + ), + DepthResolution::Agreed => {} } // VAAPI 4:4:4 is deferred (see `probe_can_encode_444`): no validated AMD/Intel hardware in the // lab exposes a HEVC 4:4:4 encode entrypoint, and the probe returns false so the host never @@ -1307,3 +1390,261 @@ impl Encoder for VaapiEncoder { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The operator pin tries exactly one mode; a cached resolution ([`LP_MODE`]) tries only the + /// mode that worked; anything else runs the full ladder, full-feature FIRST — AMD's first-try + /// open must stay byte-for-byte unchanged. + #[test] + fn entrypoint_ladder_orders_and_pins() { + assert_eq!(entrypoint_ladder(None, 0), &[false, true]); + assert_eq!(entrypoint_ladder(None, 1), &[false]); + assert_eq!(entrypoint_ladder(None, 2), &[true]); + // A corrupt/unknown cache value degrades to the full ladder, never to a wrong pin. + assert_eq!(entrypoint_ladder(None, 77), &[false, true]); + // The pin beats the cache in BOTH directions — what makes PUNKTFUNK_VAAPI_LOW_POWER a + // real escape hatch from a stale latch. + for cached in [0u8, 1, 2, 77] { + assert_eq!(entrypoint_ladder(Some(true), cached), &[true]); + assert_eq!(entrypoint_ladder(Some(false), cached), &[false]); + } + } + + /// The latch round-trip: what a successful open stores makes the NEXT open attempt exactly + /// the mode that worked (the "skip the known-failing attempt and its libav error spew" + /// contract — and, per [`LP_MODE`], the shape that must never cross devices or depths). + #[test] + fn latch_round_trip_pins_the_resolved_mode() { + for lp in [false, true] { + assert_eq!(entrypoint_ladder(None, latched_mode(lp)), &[lp]); + } + } + + /// `PUNKTFUNK_VAAPI_LOW_POWER` grammar: the two lowercase literal sets pin; anything else — + /// including uppercase — means "no pin" (the ladder runs). Case-sensitivity is pinned as + /// shipped behavior. + #[test] + fn low_power_grammar() { + for s in ["1", "true", "yes", "on", " on ", "yes\n"] { + assert_eq!(parse_low_power(s), Some(true), "{s:?}"); + } + for s in ["0", "false", "no", "off", " off "] { + assert_eq!(parse_low_power(s), Some(false), "{s:?}"); + } + for s in ["", "2", "TRUE", "On", "enabled", "low_power"] { + assert_eq!(parse_low_power(s), None, "{s:?}"); + } + } + + /// Every part of the LP_MODE key is load-bearing (see the [`LP_MODE`] field comments): the + /// node (a GPU-preference switch must re-resolve — the old codec-only key was a + /// session-killer), the codec, and the depth (an 8-bit answer must never pin the 10-bit open — + /// the HDR under-advertisement). + #[test] + fn lp_key_separates_node_codec_and_depth() { + let base = lp_key_for("/dev/dri/renderD128", Codec::H265, false); + assert_ne!(base, lp_key_for("/dev/dri/renderD129", Codec::H265, false)); + assert_ne!(base, lp_key_for("/dev/dri/renderD128", Codec::H264, false)); + assert_ne!(base, lp_key_for("/dev/dri/renderD128", Codec::H265, true)); + } + + /// `PUNKTFUNK_VAAPI_ASYNC_DEPTH` grammar: 1..=8 verbatim; unset, junk, zero, and past-the-cap + /// all resolve to the lowest-latency depth 1. + #[test] + fn async_depth_grammar() { + assert_eq!(async_depth(None), 1); + assert_eq!(async_depth(Some("1")), 1); + assert_eq!(async_depth(Some("2")), 2); + assert_eq!(async_depth(Some("8")), 8); + assert_eq!(async_depth(Some("0")), 1); + assert_eq!(async_depth(Some("9")), 1); + assert_eq!(async_depth(Some("-1")), 1); + assert_eq!(async_depth(Some("fast")), 1); + } + + /// The swscale source map: packed RGB/BGR and the GNOME 50+ HDR 2:10:10:10 layouts convert; + /// planar/video formats are refused (the CPU path is a packed-RGB fallback, not a general + /// converter). + #[test] + fn sws_src_accepts_packed_rgb_only() { + assert_eq!(vaapi_sws_src(PixelFormat::Bgrx).unwrap(), Pixel::BGRZ); + assert_eq!(vaapi_sws_src(PixelFormat::Rgbx).unwrap(), Pixel::RGBZ); + assert_eq!(vaapi_sws_src(PixelFormat::Bgra).unwrap(), Pixel::BGRA); + assert_eq!(vaapi_sws_src(PixelFormat::Rgba).unwrap(), Pixel::RGBA); + assert_eq!(vaapi_sws_src(PixelFormat::Rgb).unwrap(), Pixel::RGB24); + assert_eq!(vaapi_sws_src(PixelFormat::Bgr).unwrap(), Pixel::BGR24); + assert_eq!( + vaapi_sws_src(PixelFormat::X2Rgb10).unwrap(), + Pixel::X2RGB10LE + ); + assert_eq!( + vaapi_sws_src(PixelFormat::X2Bgr10).unwrap(), + Pixel::X2BGR10LE + ); + for f in [ + PixelFormat::Nv12, + PixelFormat::P010, + PixelFormat::Rgb10a2, + PixelFormat::Yuv444, + ] { + assert!(vaapi_sws_src(f).is_err(), "{f:?} must be refused"); + } + } + + /// VUI ↔ CSC agreement: the signaled VUI and the zero-copy VPP's pinned output colour must + /// name the SAME matrix/range per depth — a mismatch is exactly the Mesa-BT.601 hue shift the + /// pin exists to prevent. + #[test] + fn vui_and_scale_args_agree_per_depth() { + let sdr = vui_for(false); + assert!(matches!(sdr.colorspace, ffi::AVColorSpace::AVCOL_SPC_BT709)); + assert!(matches!(sdr.range, ffi::AVColorRange::AVCOL_RANGE_MPEG)); + assert!(matches!( + sdr.primaries, + ffi::AVColorPrimaries::AVCOL_PRI_BT709 + )); + assert!(matches!( + sdr.trc, + ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709 + )); + let args = scale_vaapi_args(false).to_str().unwrap(); + for needle in ["format=nv12", "out_color_matrix=bt709", "out_range=limited"] { + assert!( + args.contains(needle), + "SDR scale args miss {needle}: {args}" + ); + } + + let hdr = vui_for(true); + assert!(matches!( + hdr.colorspace, + ffi::AVColorSpace::AVCOL_SPC_BT2020_NCL + )); + assert!(matches!(hdr.range, ffi::AVColorRange::AVCOL_RANGE_MPEG)); + assert!(matches!( + hdr.primaries, + ffi::AVColorPrimaries::AVCOL_PRI_BT2020 + )); + assert!(matches!( + hdr.trc, + ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084 + )); + let args = scale_vaapi_args(true).to_str().unwrap(); + for needle in [ + "format=p010", + "out_color_matrix=bt2020", + "out_range=limited", + ] { + assert!( + args.contains(needle), + "HDR scale args miss {needle}: {args}" + ); + } + } + + /// The honest-downgrade table at open: PQ pixels demand a 10-bit session (refused otherwise — + /// PQ content must never be mislabeled BT.709); a 10-bit session over SDR pixels downgrades + /// honestly to 8-bit; agreement passes both ways. + #[test] + fn depth_resolution_table() { + use DepthResolution::*; + assert_eq!(resolve_depth(PixelFormat::X2Rgb10, 8), RefuseMislabeledPq); + assert_eq!(resolve_depth(PixelFormat::X2Bgr10, 8), RefuseMislabeledPq); + assert_eq!(resolve_depth(PixelFormat::Bgrx, 10), SdrDowngrade); + assert_eq!(resolve_depth(PixelFormat::Bgrx, 8), Agreed); + assert_eq!(resolve_depth(PixelFormat::X2Rgb10, 10), Agreed); + assert_eq!(resolve_depth(PixelFormat::X2Bgr10, 10), Agreed); + } + + /// Main10 is pinned explicitly for 10-bit HEVC ONLY: 10-bit AV1 is input-driven (no profile + /// knob), and every 8-bit open keeps the encoder's default profile. + #[test] + fn explicit_profile_is_hevc_main10_only() { + assert_eq!(explicit_profile(Codec::H265, true), Some("main10")); + assert_eq!(explicit_profile(Codec::H265, false), None); + assert_eq!(explicit_profile(Codec::Av1, true), None); + assert_eq!(explicit_profile(Codec::Av1, false), None); + assert_eq!(explicit_profile(Codec::H264, true), None); + assert_eq!(explicit_profile(Codec::H264, false), None); + } + + /// The 10-bit probe gate: HEVC and AV1 are probe-eligible; H.264 (no 10-bit path here) and + /// PyroWave (answers 10-bit on its own path, no VAAPI involved) are refused before any device + /// is touched — this is what keeps the probe safe on GPU-less CI. + #[test] + fn ten_bit_probe_gate() { + assert!(ten_bit_probe_eligible(Codec::H265)); + assert!(ten_bit_probe_eligible(Codec::Av1)); + assert!(!ten_bit_probe_eligible(Codec::H264)); + assert!(!ten_bit_probe_eligible(Codec::PyroWave)); + } + + /// Probe smoke on real silicon: H.264 VAAPI encode opens on every supported AMD/Intel GPU; + /// the narrower codecs are printed, not asserted (device-dependent). Build anywhere, run on a + /// VAAPI host: + /// cargo test -p pf-encode --no-run + /// target/debug/deps/pf_encode- --ignored --nocapture vaapi_probe_smoke + #[test] + #[ignore = "needs a real VAAPI device (run on an AMD/Intel host, not the build box)"] + fn vaapi_probe_smoke() { + assert!( + probe_can_encode(Codec::H264), + "H.264 VAAPI encode should open on any supported AMD/Intel GPU" + ); + for codec in [Codec::H265, Codec::Av1] { + eprintln!("probe_can_encode({codec:?}) = {}", probe_can_encode(codec)); + eprintln!( + "probe_can_encode_10bit({codec:?}) = {}", + probe_can_encode_10bit(codec) + ); + } + } + + /// CPU-path encode round-trip on real silicon: open → BGRX frames → poll AUs → the first AU + /// is the IDR. Exercises the swscale CSC, the VA surface upload, and the entrypoint ladder + /// end-to-end (same recipe as [`vaapi_probe_smoke`]). + #[test] + #[ignore = "needs a real VAAPI device (run on an AMD/Intel host, not the build box)"] + fn vaapi_cpu_encode_smoke() { + let (w, h) = (256u32, 256u32); + let mut enc = VaapiEncoder::open( + Codec::H264, + PixelFormat::Bgrx, + w, + h, + 30, + 2_000_000, + 8, + crate::ChromaFormat::Yuv420, + ) + .expect("open"); + let mut aus = Vec::new(); + for i in 0..30u32 { + let mut buf = vec![0u8; (w * h * 4) as usize]; + for px in buf.chunks_exact_mut(4) { + px.copy_from_slice(&[(i * 8) as u8, 0x40, 0xC0, 0xFF]); + } + let frame = CapturedFrame { + width: w, + height: h, + pts_ns: u64::from(i) * 33_333_333, + format: PixelFormat::Bgrx, + payload: FramePayload::Cpu(buf), + cursor: None, + }; + enc.submit(&frame).expect("submit"); + while let Some(au) = enc.poll().expect("poll") { + aus.push(au); + } + } + enc.flush().expect("flush"); + while let Some(au) = enc.poll().expect("poll") { + aus.push(au); + } + assert!(!aus.is_empty(), "no AUs out of 30 submitted frames"); + assert!(aus[0].keyframe, "the first AU must be the IDR"); + } +}