//! Intel **QSV** (and, retained-but-no-longer-dispatched, AMD **AMF**) hardware encode on Windows
//! via `ffmpeg-next` — the Windows analogue of the Linux [`super::vaapi`] backend (one libavcodec
//! backend per vendor, selected by encoder name: `*_qsv` / `*_amf`). Sibling of the direct-SDK
//! [`super::nvenc`] path behind the shared [`Encoder`] trait.
//!
//! **Dispatch (design/native-amf-encoder.md Phase 3):** [`super::open_video`] routes AMD to the
//! direct-SDK [`super::amf`] encoder, not this module — the libavcodec AMF wrapper's ~2-frame
//! output hold and its silent-wedge failure mode are exactly why the native path exists. So in
//! production this file serves **QSV only**. The `WinVendor::Amf` machinery is kept (not deleted)
//! because it is the comparator in the native-vs-libavcodec latency A/B (`amf::tests::
//! amf_latency_ab_bench`), and excising it would churn the shared, Intel-unvalidated QSV code for
//! no production benefit. Treat every `WinVendor::Amf` arm below as benchmark-only.
//!
//! The capturer hands a `FramePayload::D3d11` texture (NV12/P010 from the D3D11 video processor, or
//! BGRA/Rgb10a2 as a fallback) on the capturer's own `ID3D11Device`. Two input paths, chosen lazily
//! from the first frame and the `PUNKTFUNK_ZEROCOPY` knob:
//!
//! * **System-memory** ([`SystemInner`]): read the captured D3D11 surface back to a CPU
//!   NV12/P010 [`AVFrame`] (a same-format `CopyResource` → staging → `Map`, plus a `swscale` step for
//!   the BGRA fallback) and `avcodec_send_frame` it. AMF/QSV upload it internally. One
//!   GPU→CPU→GPU round-trip per frame — the robust path, the QSV default, and the automatic
//!   fallback when the zero-copy setup fails (it is the analogue of the VAAPI "CPU input" fallback).
//! * **Zero-copy D3D11** ([`ZeroCopyInner`], the AMF default; see [`zerocopy_enabled`]): wrap the
//!   capturer's `ID3D11Device` as an `AV_HWDEVICE_TYPE_D3D11VA` hwdevice (shared, *not* a second
//!   device — the capture textures are not shared-handle, so a different device couldn't read them),
//!   keep an FFmpeg D3D11 frames pool, `CopySubresourceRegion` the captured texture into a pooled
//!   array slice (a GPU-local copy, like NVENC's CUDA path), then feed AMF `AV_PIX_FMT_D3D11`
//!   directly, or map the D3D11 frame to a derived QSV surface for QSV. If the hw setup fails to
//!   open, this falls back to the system-memory path for the session.
//!
//! **Status:** AMF on-glass validated 2026-07-06 (Ryzen 7000 iGPU, 1080p120 HDR P010, both input
//! paths; zero-copy cut `submit_us` p50 2.8 ms → 0.26 ms) — zero-copy is the AMF default. QSV is
//! still not on-glass validated (no Intel Windows box in the lab), so its zero-copy path stays
//! opt-in via `PUNKTFUNK_ZEROCOPY=1`.
//!
//! Raw FFI: `ffmpeg-next` has no hwcontext wrappers for D3D11VA, so the hwdevice/hwframes calls go
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
//! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`.

use super::{ChromaFormat, Codec, EncodedFrame, Encoder};
use anyhow::{anyhow, bail, Context, Result};
use ffmpeg::format::Pixel;
use ffmpeg::{codec, encoder, Dictionary};
use ffmpeg_next as ffmpeg;
use pf_frame::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat};
use std::os::raw::{c_int, c_uint, c_void};
use std::ptr;
use windows::core::Interface;
use windows::Win32::Graphics::Direct3D11::{
    ID3D11Device, ID3D11DeviceContext, ID3D11Multithread, ID3D11Resource, ID3D11Texture2D,
    D3D11_BIND_DECODER, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE,
    D3D11_BIND_VIDEO_ENCODER, D3D11_CPU_ACCESS_READ, D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ,
    D3D11_TEXTURE2D_DESC, D3D11_USAGE_STAGING,
};
use windows::Win32::Graphics::Dxgi::Common::{
    DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010,
    DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_SAMPLE_DESC,
};

use super::libav::{
    apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFrame, AvSwsContext, PollOutcome,
    SWS_CS_BT2020, SWS_CS_ITU709, SWS_POINT,
};
use ffmpeg::ffi; // = ffmpeg_sys_next

/// `AVD3D11VADeviceContext` (libavutil/hwcontext_d3d11va.h) — mirrored (the ffmpeg-sys bindings
/// don't allowlist that header). We set `device` to the capturer's `ID3D11Device` so AMF/QSV share
/// it; `av_hwdevice_ctx_init` fills `device_context`/`video_device`/`video_context`/the default
/// lock from a non-null `device`.
#[repr(C)]
struct AVD3D11VADeviceContext {
    device: *mut c_void,         // ID3D11Device*
    device_context: *mut c_void, // ID3D11DeviceContext*
    video_device: *mut c_void,   // ID3D11VideoDevice*
    video_context: *mut c_void,  // ID3D11VideoContext*
    lock: *mut c_void,           // void (*)(void*)
    unlock: *mut c_void,         // void (*)(void*)
    lock_ctx: *mut c_void,
    // DELIBERATELY TRUNCATED: FFmpeg >=8 appends `UINT BindFlags; UINT MiscFlags;` here, FFmpeg 7.1
    // does not, and we build against both (Windows links the BtbN n7.1 tree, Linux the distro's 8 or
    // 9). Mirroring only the common prefix is what makes one definition correct for all three —
    // libav owns the allocation (av_hwdevice_ctx_alloc sizes it), and we only ever WRITE `device` at
    // offset 0, so a short mirror can never read or write past what libav allocated. Adding the two
    // flags to match 8/9 would silently mis-describe the 7.1 build we actually ship on Windows.
    // The per-pool AVD3D11VAFramesContext.BindFlags below — which we DO set — exists in all three.
}

/// `AVD3D11VAFramesContext` (libavutil/hwcontext_d3d11va.h) — mirrored. `BindFlags`/`MiscFlags`
/// customise the texture-array FFmpeg allocates for the pool; `texture` (we leave null) would let us
/// supply our own array.
#[repr(C)]
struct AVD3D11VAFramesContext {
    texture: *mut c_void,       // ID3D11Texture2D*
    bind_flags: c_uint,         // UINT BindFlags
    misc_flags: c_uint,         // UINT MiscFlags
    texture_infos: *mut c_void, // AVD3D11FrameDescriptor* (FFmpeg-owned; we never touch it)
}

// Hand-written mirrors of libav's `AVD3D11VADeviceContext` / `AVD3D11VAFramesContext`
// (hwcontext_d3d11va.h) — `ffmpeg-sys-next` binds neither, and we WRITE `device` / `bind_flags`
// through them, so a wrong offset is silent corruption of libav's context rather than a compile
// error.
//
// ⚠ KNOW WHAT THESE ASSERTIONS DO AND DO NOT BUY YOU. They pin OUR layout, not libav's, so they
// turn an accidental edit to the structs above into a build failure — but nothing here reads
// hwcontext_d3d11va.h, so a field libav inserts upstream still sails straight through, and a green
// build is not evidence. Both layouts were therefore re-checked BY HAND against FFmpeg 7.1, 8.1.2
// and 9.0 during the 8 -> 9 bump (2026-08-08): `AVD3D11VAFramesContext` is byte-identical in all
// three, and `AVD3D11VADeviceContext` gained two trailing UINTs in 8 that 7.1 lacks — which is
// exactly why the struct above stops at the common prefix. Re-check by hand on the next FFmpeg
// major. (An older note here claimed these were duplicated in pf-client-core's `video_d3d11.rs`;
// that copy went away with the client's FFmpeg in M10, so this is now the only definition.)
const _: () = {
    use std::mem::{offset_of, size_of};
    type P = *mut c_void;
    assert!(size_of::<AVD3D11VADeviceContext>() == 7 * size_of::<P>());
    assert!(offset_of!(AVD3D11VADeviceContext, device) == 0);
    assert!(offset_of!(AVD3D11VADeviceContext, device_context) == size_of::<P>());
    assert!(offset_of!(AVD3D11VADeviceContext, video_device) == 2 * size_of::<P>());
    assert!(offset_of!(AVD3D11VADeviceContext, video_context) == 3 * size_of::<P>());
    assert!(offset_of!(AVD3D11VADeviceContext, lock) == 4 * size_of::<P>());
    assert!(offset_of!(AVD3D11VADeviceContext, unlock) == 5 * size_of::<P>());
    assert!(offset_of!(AVD3D11VADeviceContext, lock_ctx) == 6 * size_of::<P>());
    // ptr, u32, u32, ptr — the two 32-bit flags pack into one pointer-sized slot with no padding.
    assert!(size_of::<AVD3D11VAFramesContext>() == 3 * size_of::<P>());
    assert!(offset_of!(AVD3D11VAFramesContext, texture) == 0);
    assert!(offset_of!(AVD3D11VAFramesContext, bind_flags) == size_of::<P>());
    assert!(offset_of!(AVD3D11VAFramesContext, misc_flags) == size_of::<P>() + 4);
    assert!(offset_of!(AVD3D11VAFramesContext, texture_infos) == 2 * size_of::<P>());
};

/// AMD AMF vs Intel QSV — the two libavcodec vendor backends this module covers.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WinVendor {
    /// Benchmark-only, as the module header explains: native AMF replaced the libavcodec AMF path
    /// in production, and the only remaining CONSTRUCTOR is the `#[cfg(feature = "amf-qsv")]`
    /// latency A/B in `amf.rs` — the measurement that justifies the native backend existing. That
    /// is test code, so the *lib* target constructs it nowhere and `dead_code` fires on it (the
    /// crate root no longer blanket-allows that). Kept deliberately rather than deleted; the arms
    /// below are what the benchmark drives.
    #[allow(dead_code)]
    Amf,
    Qsv,
}

impl WinVendor {
    fn encoder_name(self, codec: Codec) -> &'static str {
        match self {
            WinVendor::Amf => codec.amf_name(),
            WinVendor::Qsv => codec.qsv_name(),
        }
    }

    fn label(self) -> &'static str {
        match self {
            WinVendor::Amf => "AMF",
            WinVendor::Qsv => "QSV",
        }
    }
}

/// Is the zero-copy D3D11 path enabled for this vendor? An explicit `PUNKTFUNK_ZEROCOPY`
/// (`0|false|off|no` = off, anything else = on) overrides; unset defers to the per-vendor default:
/// **on for AMF** — on-glass validated 2026-07-06 (Ryzen iGPU, 1080p120 HDR P010: `submit_us` p50
/// 2.8 ms → 0.26 ms vs readback) — and **off for QSV** until validated on Intel glass (the
/// open-failure fallback only catches *setup* errors; a derive that opens but maps wrong would
/// corrupt silently, so it stays opt-in per the probe-never-assume rule).
fn zerocopy_enabled(vendor: WinVendor) -> bool {
    zerocopy_active(pf_host_config::config().zerocopy, vendor)
}

/// The pure half of [`zerocopy_enabled`]: an operator override wins; unset resolves to the
/// per-vendor default (AMF on, QSV off — see the validation status above).
fn zerocopy_active(override_: Option<bool>, vendor: WinVendor) -> bool {
    override_.unwrap_or(matches!(vendor, WinVendor::Amf))
}

/// Upper bound on `PUNKTFUNK_FFWIN_POLL_MS`. This knob spins the **encode thread** waiting for an
/// AU, so a value past one frame period is already self-defeating and a full second is far beyond
/// anything an operator would set on purpose. The clamp is also what makes the µs conversion
/// below provably overflow-free.
///
/// The reachable hazard is a slipped digit, not the overflow: pre-clamp, `PUNKTFUNK_FFWIN_POLL_MS=
/// 100000000` was a **27.7-hour** spin with no overflow anywhere near it.
const MAX_POLL_SPIN_MS: u64 = 1_000;

/// Bounded post-submit spin for [`FfmpegWinEncoder::poll`], in microseconds (0 = off, the default
/// and the correct choice on every VCN measured so far).
///
/// Read from the environment **once per process** (WP6.1): `poll` runs once per encode tick, and
/// this was an unconditional `env::var` + parse on it.
///
/// ⚠ The audit proposed `saturating_mul` for the µs conversion. It is still the wrong fix, but for
/// a reason worth stating precisely, because the obvious one is false: `Duration::from_micros(
/// u64::MAX)` is only ~1.8e13 seconds, six orders of magnitude below `Duration`'s `u64::MAX`-second
/// ceiling, so `Instant::now() + Duration::from_micros(u64::MAX)` does **not** overflow and does
/// **not** panic (measured, both with and without debug assertions). What it does instead is set a
/// deadline ~584,000 years out, and the loop below only exits on `Packet`/`Eof` — and this
/// function's own doc explains that a spin here *provably never* produces the owed AU on the
/// measured hardware. So `saturating_mul` converts a bad value into a **permanently wedged encode
/// thread**: a hang, not a panic. Clamping the parsed value first removes the bad value entirely.
///
/// (For the record on the pre-clamp behaviour: the workspace sets no `overflow-checks` in
/// `[profile.release]`, so `ms * 1000` wrapped silently in release and panicked only in debug.)
fn poll_spin_cap_us() -> u64 {
    static CAP_US: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
    *CAP_US.get_or_init(|| {
        parse_poll_spin_cap_us(std::env::var("PUNKTFUNK_FFWIN_POLL_MS").ok().as_deref())
    })
}

/// The pure half of [`poll_spin_cap_us`]: parse, clamp to [`MAX_POLL_SPIN_MS`] BEFORE the µs
/// conversion (the ordering the doc above proves is load-bearing), and default to 0 — no spin,
/// the libavcodec AMF buffer can't be spun out.
fn parse_poll_spin_cap_us(raw: Option<&str>) -> u64 {
    raw.and_then(|s| s.trim().parse::<u64>().ok())
        .map(|ms| ms.min(MAX_POLL_SPIN_MS) * 1000)
        .unwrap_or(0)
}

/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
fn sws_src(format: PixelFormat) -> Result<Pixel> {
    Ok(match format {
        PixelFormat::Bgrx => Pixel::BGRZ,
        PixelFormat::Rgbx => Pixel::RGBZ,
        PixelFormat::Bgra => Pixel::BGRA,
        PixelFormat::Rgba => Pixel::RGBA,
        PixelFormat::Rgb => Pixel::RGB24,
        PixelFormat::Bgr => Pixel::BGR24,
        // X2Rgb10/X2Bgr10 are the Linux GNOME 50 HDR screencast formats — the Windows HDR path
        // stays Rgb10a2/P010, so they can't reach this capture-side conversion. Listed explicitly
        // (not via `_`) so the next PixelFormat addition breaks this match again on purpose.
        PixelFormat::Nv12
        | PixelFormat::P010
        | PixelFormat::Rgb10a2
        | PixelFormat::Yuv444
        | PixelFormat::X2Rgb10
        | PixelFormat::X2Bgr10 => {
            bail!("ffmpeg_win swscale path supports packed RGB/BGR only; got {format:?}")
        }
    })
}

/// Does this captured format imply a 10-bit encode (P010 / Rgb10a2)?
///
/// Depth follows the PIXELS, not the negotiated `bit_depth` — see
/// [`crate::ten_bit_input`] for why, and for the failure this shape used to produce here in
/// particular: a 10-bit-negotiated session over an 8-bit capture built a P010 encoder whose every
/// `submit_d3d11` then failed the depth check below, forever, with `reset()` unable to help
/// because the rebuild re-derived the same wrong answer.
fn is_10bit_format(format: PixelFormat) -> bool {
    matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
}

/// Which lane the system-memory path routes a captured D3D11 format through. Device-free — the
/// routing DECISION, split from the D3D11 copies so it is testable.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ReadbackRoute {
    /// Same-format `CopyResource` + plane-by-plane copy (NV12/P010 from the video processor).
    Yuv,
    /// BGRA staging + swscale BGRA→NV12 — the 8-bit fallback when the capturer's video
    /// processor latched off.
    Bgra,
    /// R10G10B10A2 staging + swscale X2BGR10→P010 — the HDR twin of that fallback.
    Rgb10,
}

/// Route a captured format, guarding the mid-stream depth change first: the predicate matches
/// what the encoder was built from (`ten_bit_input`), so the guard can only fire on a GENUINE
/// depth change under the encoder — never, as it used to, on every frame of a session that
/// merely negotiated 10-bit over an 8-bit capture (see [`is_10bit_format`]).
fn readback_route(format: PixelFormat, ten_bit: bool) -> Result<ReadbackRoute> {
    anyhow::ensure!(
        is_10bit_format(format) == ten_bit,
        "captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
        if ten_bit { 10 } else { 8 }
    );
    Ok(match format {
        PixelFormat::Nv12 | PixelFormat::P010 => ReadbackRoute::Yuv,
        PixelFormat::Bgra | PixelFormat::Bgrx => ReadbackRoute::Bgra,
        PixelFormat::Rgb10a2 => ReadbackRoute::Rgb10,
        other => {
            bail!("ffmpeg_win system path cannot read back captured D3D11 format {other:?}")
        }
    })
}

/// The vendor-specific low-latency option set for [`open_win_encoder`], pure so the latency
/// contract is pinned by tests. Unknown private options are ignored by `avcodec_open2` (left in
/// the dict), so vendor/codec-specific keys are safe to set unconditionally.
fn vendor_opts(vendor: WinVendor, amf_usage: &str) -> Vec<(&'static str, String)> {
    match vendor {
        WinVendor::Amf => vec![
            // Field-tuning override (ultralowlatency | lowlatency | lowlatency_high_quality |
            // transcoding): AMF usage presets bundle driver-side pipeline behavior that varies
            // by VCN generation/driver — measured on-box rather than assumed.
            ("usage", amf_usage.to_owned()),
            ("rc", "cbr".into()),
            // Streaming is latency-first: `speed` trims per-frame motion-estimation depth — the
            // difference between ~encode-time and ~frame-budget on iGPU-class VCN (matches the
            // low-latency preset choice on the NVENC path).
            ("quality", "speed".into()),
            ("preanalysis", "false".into()),
            ("enforce_hrd", "true".into()),
            // AMF low-latency submission mode (FFmpeg ≥ 6.1; unknown-option-ignored on older).
            ("latency", "true".into()),
            // Never B-frames: h264_amf defaults >0 on RDNA3+ HW that supports them, and each
            // B-frame is a full frame period of added latency. (HEVC VCN has none; ignored there.)
            ("bf", "0".into()),
            // VPS/SPS/PPS on each IDR (clean mid-stream join) — HEVC/AV1 only; ignored elsewhere.
            ("header_insertion_mode", "idr".into()),
        ],
        WinVendor::Qsv => vec![
            ("preset", "veryfast".into()),
            ("async_depth", "1".into()), // bound in-flight frames — the big QSV latency lever
            ("low_power", "1".into()),   // VDEnc fixed-function path (lower latency)
            ("look_ahead", "0".into()),  // (h264_qsv only; ignored on hevc/av1)
            ("forced_idr", "1".into()),  // a forced key frame becomes a real IDR
            ("scenario", "displayremoting".into()),
        ],
    }
}

/// Bind flags on the FFmpeg-allocated zero-copy pool. AMF reads it as encoder input
/// (RENDER_TARGET + SHADER_RESOURCE, matching the video-processor output); QSV maps it as an mfx
/// surface (DECODER | VIDEO_ENCODER). The `CopySubresourceRegion` into the pool works with any
/// usable DEFAULT-usage texture regardless.
fn pool_bind_flags(vendor: WinVendor) -> u32 {
    match vendor {
        WinVendor::Amf => (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
        WinVendor::Qsv => (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32,
    }
}

/// Build the FFmpeg encoder context shared by both inner paths: name, mode, low-latency RC,
/// infinite GOP, the BT.709-limited (SDR) or BT.2020-PQ (HDR) VUI, the given `pix_fmt`, and the
/// optional hw device/frames contexts (null for the system path). Returns the opened encoder.
#[allow(clippy::too_many_arguments)]
unsafe fn open_win_encoder(
    vendor: WinVendor,
    codec: Codec,
    width: u32,
    height: u32,
    fps: u32,
    bitrate_bps: u64,
    pix_fmt: ffi::AVPixelFormat,
    sw_pix_fmt: ffi::AVPixelFormat,
    ten_bit: bool,
    device_ref: *mut ffi::AVBufferRef,
    frames_ref: *mut ffi::AVBufferRef,
) -> Result<encoder::video::Encoder> {
    let name = vendor.encoder_name(codec);
    let av_codec = encoder::find_by_name(name).ok_or_else(|| {
        anyhow!(
            "{name} not built into libavcodec (no {} encoder)",
            vendor.label()
        )
    })?;
    let mut video = codec::context::Context::new_with_codec(av_codec)
        .encoder()
        .video()
        .context("alloc video encoder")?;
    video.set_width(width);
    video.set_height(height);
    // 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));
    // Fixed rate, CBR, no B-frames, ~1-frame VBV — the shared low-latency RC contract.
    apply_low_latency_rc(&mut video, fps, bitrate_bps);
    // SAFETY: `as_mut_ptr` hands back the `AVCodecContext` behind the `video` encoder allocated just
    // above, which outlives every write here (it is opened and returned below). The gop/colour/
    // pix_fmt stores are in-bounds scalar field writes on that live context. `device_ref` and
    // `frames_ref` are valid `AVBufferRef`s by this fn's contract OR null — the system path passes
    // null for both — and the `is_null` guards keep `av_buffer_ref` off the null case; each call
    // returns a NEW reference that the codec context adopts and unrefs when freed, so this shares
    // the caller's buffers rather than taking them over.
    let raw = unsafe { video.as_mut_ptr() };
    // SAFETY: as above — `raw` is that live `AVCodecContext` and every store below is an in-bounds
    // field write on it, with the two `av_buffer_ref` calls guarded against null.
    unsafe {
        (*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
            // the HEVC VUI; the static mastering metadata also rides the 0xCE datagram out-of-band.
            (*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 (video-processor or swscale CSC), 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;
        }
        (*raw).pix_fmt = pix_fmt;
        if !device_ref.is_null() {
            (*raw).hw_device_ctx = ffi::av_buffer_ref(device_ref);
        }
        if !frames_ref.is_null() {
            (*raw).hw_frames_ctx = ffi::av_buffer_ref(frames_ref);
        }
    }

    // Low-latency tuning — the per-vendor contract lives in `vendor_opts` (pure, test-pinned).
    let mut opts = Dictionary::new();
    let usage = std::env::var("PUNKTFUNK_AMF_USAGE").unwrap_or_else(|_| "ultralowlatency".into());
    for (k, v) in vendor_opts(vendor, &usage) {
        opts.set(k, &v);
    }
    video
        .open_with(opts)
        .with_context(|| format!("open {name} ({width}x{height}@{fps}, {bitrate_bps} bps)"))
}

/// Probe whether THIS GPU can `vendor`-encode `codec`, by opening a tiny system-input encoder. The
/// driver/runtime rejects codecs the video engine can't do (AV1 on pre-RDNA3 AMD / pre-Arc Intel,
/// or HEVC on a very old part). Used to build the GameStream codec advertisement so a client never
/// negotiates a codec the encoder can't open. Torn down immediately.
/// Whether the active AMD (AMF) / Intel (QSV) GPU can encode HEVC **4:4:4**. **Deferred in v1 —
/// always `false`.** AMF/QSV HEVC 4:4:4 encode is narrow (AMD RDNA3+, Intel Arc/Xe2+) and the
/// libavcodec profile/pixel-format incantation is vendor- and driver-specific — a wrong profile
/// `avcodec_open2` *silently* falls back to 4:2:0, so a positive probe would need a verify-by-frame,
/// and there is no AMD/Intel Windows box in the lab to build + validate that against. Returning
/// `false` keeps the negotiation honest: an AMF/QSV host resolves every session to 4:2:0 before the
/// Welcome. (Follow-up: implement + validate on an RDNA3+/Arc Windows box.)
pub fn probe_can_encode_444(_vendor: WinVendor, _codec: Codec) -> bool {
    tracing::debug!("AMF/QSV HEVC 4:4:4 encode not implemented — declining (4:2:0)");
    false
}

/// Gated to the builds that can actually reach it: `lib.rs`'s only caller sits under
/// `cfg(all(not(feature = "qsv"), feature = "amf-qsv"))`, because with the native VPL backend
/// compiled in it is `qsv::probe_can_encode` that answers. So in the SHIPPED Windows combo
/// (`nvenc,amf-qsv,qsv`) this function has no caller at all.
#[cfg(not(feature = "qsv"))]
pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
    // Deliberately NOT pinned to the selected render adapter (unlike `nvenc::probe_can_encode_444`):
    // the system-input probe passes no hwdevice, and the AMF/QSV runtimes only ever bind their own
    // vendor's silicon — on a mixed-vendor box the probe lands on the right GPU by construction.
    // Only a two-same-vendor-GPU box could probe the wrong card (accepted; results are cached per
    // selected GPU in `windows_codec_support`, so a fix here slots in without churn).
    if ffmpeg::init().is_err() {
        return false;
    }
    // SAFETY: `ffmpeg::init()` succeeded above, so libav's global state is initialised.
    // `av_log_get_level`/`av_log_set_level` are global scalar getters/setters with no pointer args.
    // `open_win_encoder` (the `unsafe fn`) is called with null `device_ref`/`frames_ref` (the system
    // path), so it touches no D3D11/hwcontext — it only allocates and opens a self-contained
    // libavcodec encoder that is dropped at the end of `.is_ok()`. We restore the prior log level and
    // no raw pointer escapes the block.
    unsafe {
        // A missing AMF/QSV runtime (wrong-vendor host, GPU-less CI) is an expected probe outcome —
        // quiet ffmpeg's open error for the probe, then restore the level.
        let prev = ffi::av_log_get_level();
        ffi::av_log_set_level(ffi::AV_LOG_FATAL);
        let ok = open_win_encoder(
            vendor,
            codec,
            640,
            480,
            30,
            2_000_000,
            ffi::AVPixelFormat::AV_PIX_FMT_NV12,
            ffi::AVPixelFormat::AV_PIX_FMT_NV12,
            false,
            ptr::null_mut(),
            ptr::null_mut(),
        )
        .is_ok();
        ffi::av_log_set_level(prev);
        ok
    }
}

/// The immediate context of an `ID3D11Device` (for `CopyResource`/`CopySubresourceRegion`).
///
/// Safe: `&ID3D11Device` is a borrowed, reference-counted COM wrapper, so the borrow itself is the
/// "live device" guarantee, and the returned context owns its own reference.
fn immediate_context(device: &ID3D11Device) -> ID3D11DeviceContext {
    // windows-rs 0.62: the inherent method takes no args and returns the context (the OutRef form is
    // only on the `_Impl` trait, for implementing the interface). Every D3D11 device has one.
    // SAFETY: a `?`-free COM call on the live `device` borrow; it takes no pointers and every
    // D3D11 device has an immediate context, so the `expect` is unreachable in practice.
    unsafe {
        device
            .GetImmediateContext()
            .expect("ID3D11Device always has an immediate context")
    }
}

// ---------------------------------------------------------------------------------------------
// System-memory path (default): read the captured D3D11 surface back to a CPU NV12/P010 frame.
// ---------------------------------------------------------------------------------------------

struct SystemInner {
    enc: encoder::video::Encoder,
    // FIELD ORDER IS LOAD-BEARING: the hand-written `Drop` this replaced freed `sw_frame`
    // before `sws`, and field-DECLARATION order is what preserves that now (an offset_of assert
    // cannot pin this — repr(Rust) may reorder memory independently of declaration order, and
    // drop order follows declaration).
    /// Reusable software NV12/P010 frame: swscale dst / readback dst, and the `send_frame` src.
    sw_frame: AvFrame,
    /// swscale ctx for the BGRA→NV12 fallback (built lazily; `None` for the YUV-readback path).
    sws: Option<AvSwsContext>,
    /// CPU-readable staging texture for the D3D11 readback (built lazily on the captured device).
    staging: Option<ID3D11Texture2D>,
    ctx: Option<ID3D11DeviceContext>,
    format: PixelFormat,
    ten_bit: bool,
    width: u32,
    height: u32,
}

impl SystemInner {
    #[allow(clippy::too_many_arguments)]
    fn open(
        vendor: WinVendor,
        codec: Codec,
        format: PixelFormat,
        width: u32,
        height: u32,
        fps: u32,
        bitrate_bps: u64,
        bit_depth: u8,
    ) -> Result<Self> {
        let ten_bit = crate::ten_bit_input(format, bit_depth);
        let sw_av = if ten_bit {
            ffi::AVPixelFormat::AV_PIX_FMT_P010LE
        } else {
            ffi::AVPixelFormat::AV_PIX_FMT_NV12
        };
        // SAFETY: calls the `unsafe fn open_win_encoder` with null `device_ref`/`frames_ref`, so the
        // system path is taken (no hw device/frames context is touched); all other args are scalars.
        // The returned `encoder::video::Encoder` owns its `AVCodecContext` and frees it on drop; no raw
        // pointer is aliased.
        let enc = unsafe {
            open_win_encoder(
                vendor,
                codec,
                width,
                height,
                fps,
                bitrate_bps,
                sw_av, // system input: pix_fmt == sw_format (no hw frames ctx)
                sw_av,
                ten_bit,
                ptr::null_mut(),
                ptr::null_mut(),
            )?
        };
        let sw_frame = AvFrame::alloc().context("av_frame_alloc(sw) failed")?;
        // SAFETY: writing `format`/`width`/`height` through the owned frame's pointer stays inside
        // its allocation. `av_frame_get_buffer` allocates the backing planes — on failure the
        // owned `sw_frame` simply drops (freed once, by the wrapper).
        unsafe {
            (*sw_frame.as_ptr()).format = sw_av as c_int;
            (*sw_frame.as_ptr()).width = width as c_int;
            (*sw_frame.as_ptr()).height = height as c_int;
            if ffi::av_frame_get_buffer(sw_frame.as_ptr(), 0) < 0 {
                bail!("av_frame_get_buffer(sw) failed");
            }
        }
        tracing::info!(
            encoder = vendor.encoder_name(codec),
            "{} encode active ({width}x{height}@{fps}, system-memory {} path)",
            vendor.label(),
            if ten_bit { "P010" } else { "NV12" }
        );
        Ok(SystemInner {
            enc,
            sw_frame,
            sws: None,
            staging: None,
            ctx: None,
            format,
            ten_bit,
            width,
            height,
        })
    }

    /// Lazily (re)build the staging texture matching `dxgi_fmt` on the captured device.
    ///
    /// Safe: `&ID3D11Device` is the live-device guarantee and `dxgi_fmt` is a plain enum; the
    /// texture it creates is owned by `self`.
    fn ensure_staging(&mut self, device: &ID3D11Device, dxgi_fmt: DXGI_FORMAT) -> Result<()> {
        if self.staging.is_some() {
            return Ok(());
        }
        let desc = D3D11_TEXTURE2D_DESC {
            Width: self.width,
            Height: self.height,
            MipLevels: 1,
            ArraySize: 1,
            Format: dxgi_fmt,
            SampleDesc: DXGI_SAMPLE_DESC {
                Count: 1,
                Quality: 0,
            },
            Usage: D3D11_USAGE_STAGING,
            BindFlags: 0,
            CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
            MiscFlags: 0,
        };
        let mut t: Option<ID3D11Texture2D> = None;
        // SAFETY: one `?`-checked `CreateTexture2D` on the live `device` borrow, over a
        // fully-initialized stack descriptor and a live `Option` out-param.
        unsafe {
            device
                .CreateTexture2D(&desc, None, Some(&mut t))
                .context("CreateTexture2D(staging readback)")?;
        }
        self.staging = t;
        self.ctx = Some(immediate_context(device));
        Ok(())
    }

    /// Send the reusable `sw_frame` to the encoder with the given pts / IDR flag.
    ///
    /// Safe: both arguments are scalars, and `sw_frame`/`enc` are allocations `self` owns from its
    /// constructor until `Drop` — no caller supplies or can invalidate them.
    fn send(&mut self, pts: i64, idr: bool) -> Result<()> {
        // SAFETY: `self.sw_frame` is the `AVFrame` this struct allocated and owns, so the two field
        // stores are in-bounds writes on a live allocation; `avcodec_send_frame` then takes that
        // frame and `self.enc`'s own context, both live for the call and neither retained by libav
        // (it references the frame's buffers itself).
        unsafe {
            (*self.sw_frame.as_ptr()).pts = pts;
            (*self.sw_frame.as_ptr()).pict_type = if idr {
                ffi::AVPictureType::AV_PICTURE_TYPE_I
            } else {
                ffi::AVPictureType::AV_PICTURE_TYPE_NONE
            };
            let r = ffi::avcodec_send_frame(self.enc.as_mut_ptr(), self.sw_frame.as_ptr());
            if r < 0 {
                bail!("avcodec_send_frame({} system) failed ({r})", "ffmpeg_win");
            }
        }
        Ok(())
    }

    /// D3D11 path: read the captured surface back into `sw_frame`, then send. Dispatches on the
    /// CURRENT frame's `format` — the capturer's video processor latches off on failure and switches
    /// NV12→Bgra (SDR) or P010→Rgb10a2 (HDR) mid-session, so a fixed open-time format is wrong.
    fn submit_d3d11(
        &mut self,
        frame: &D3d11Frame,
        format: PixelFormat,
        pts: i64,
        idr: bool,
    ) -> Result<()> {
        match readback_route(format, self.ten_bit)? {
            ReadbackRoute::Yuv => self.readback_yuv(frame, pts, idr),
            ReadbackRoute::Bgra => self.readback_bgra(frame, pts, idr),
            ReadbackRoute::Rgb10 => self.readback_rgb10(frame, pts, idr),
        }
    }

    /// Read back a captured NV12/P010 surface plane-by-plane into the software frame.
    fn readback_yuv(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
        let dxgi_fmt = if self.ten_bit {
            DXGI_FORMAT_P010
        } else {
            DXGI_FORMAT_NV12
        };
        // SAFETY: `ensure_staging` builds a STAGING texture (CPU_ACCESS_READ) matching `dxgi_fmt` on
        // `frame.device` — the same `ID3D11Device` that owns `frame.texture` — and caches that device's
        // immediate context in `self.ctx`. `src`/`dst` are that device's textures of identical NV12/P010
        // format and dimensions, so `CopyResource` on the single-threaded immediate context is valid.
        // `Map(.., D3D11_MAP_READ)` succeeds on a staging texture and yields `map.pData` valid for the
        // whole resource; for NV12/P010 the luma plane is `H` rows at `RowPitch` and the chroma plane
        // follows at byte offset `RowPitch*H` (`H/2` rows), so `total = pitch*(H+⌈H/2⌉)` is exactly the
        // mapped extent and `from_raw_parts(base, total)` stays in-bounds. Each `copy_nonoverlapping`
        // reads a bounds-checked `mapped[..]` sub-slice (`row_bytes ≤ pitch`) and writes `row_bytes ≤
        // linesize` into the `av_frame_get_buffer`-allocated plane at row `y < H`, so every destination
        // offset is inside the frame's plane allocation; src and dst never alias. `Unmap` pairs `Map`,
        // then `send` (the `unsafe fn`) hands `sw_frame` to the encoder.
        unsafe {
            self.ensure_staging(&frame.device, dxgi_fmt)?;
            let staging = self.staging.clone().context("staging texture")?;
            let ctx = self.ctx.clone().context("d3d11 context")?;
            let src: ID3D11Resource = frame.texture.cast().context("texture -> resource")?;
            let dst: ID3D11Resource = staging.cast().context("staging -> resource")?;
            ctx.CopyResource(&dst, &src);
            let mut map = D3D11_MAPPED_SUBRESOURCE::default();
            ctx.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut map))
                .context("Map staging (yuv readback)")?;
            let pitch = map.RowPitch as usize;
            let h = self.height as usize;
            // NV12/P010 in a mapped staging surface: the Y plane occupies rows [0,H) at `pitch`; the
            // interleaved chroma plane (H/2 rows) starts at byte offset `pitch * H`. P010 samples are
            // 16-bit, so a "row" of width pixels is `width*2` bytes (and chroma `width*2` too).
            let bytes_per_sample = if self.ten_bit { 2 } else { 1 };
            let row_bytes = self.width as usize * bytes_per_sample;
            let base = map.pData as *const u8;
            let total = pitch.saturating_mul(h + h.div_ceil(2));
            let mapped = std::slice::from_raw_parts(base, total);
            let chroma_off = pitch * h;
            let y_dst = (*self.sw_frame.as_ptr()).data[0];
            let y_stride = (*self.sw_frame.as_ptr()).linesize[0] as usize;
            let uv_dst = (*self.sw_frame.as_ptr()).data[1];
            let uv_stride = (*self.sw_frame.as_ptr()).linesize[1] as usize;
            for y in 0..h {
                let s = &mapped[y * pitch..y * pitch + row_bytes];
                ptr::copy_nonoverlapping(s.as_ptr(), y_dst.add(y * y_stride), row_bytes);
            }
            for y in 0..h.div_ceil(2) {
                let s = &mapped[chroma_off + y * pitch..chroma_off + y * pitch + row_bytes];
                ptr::copy_nonoverlapping(s.as_ptr(), uv_dst.add(y * uv_stride), row_bytes);
            }
            ctx.Unmap(&staging, 0);
            self.send(pts, idr)
        }
    }

    /// Read back a captured BGRA surface, then swscale BGRA→NV12 into the software frame (8-bit).
    fn readback_bgra(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
        if self.ten_bit {
            bail!("ffmpeg_win: BGRA readback is 8-bit only (HDR needs the P010 capture path)");
        }
        // SAFETY: `ensure_staging` builds a B8G8R8A8 STAGING texture on `frame.device` and caches that
        // device's immediate context; `src`/`dst` are that device's textures of matching BGRA format,
        // so `CopyResource` on the single-threaded context is valid. `Map(READ)` on the staging texture
        // yields `base` valid for `pitch` × `h` rows. `ensure_sws` lazily builds the BGRA→NV12 context;
        // `sws_scale` reads `h` rows of `pitch` bytes from `base` (in-bounds — the staging surface is
        // `≥ pitch*h`) into the `sw_frame` planes addressed by its `data`/`linesize` (allocated for
        // `width`×`height` NV12). `Unmap` pairs `Map`; the cached `sws` is freed once in `Drop`. The
        // mapped read region never aliases the owned encoder frame.
        unsafe {
            self.ensure_staging(&frame.device, DXGI_FORMAT_B8G8R8A8_UNORM)?;
            let staging = self.staging.clone().context("staging texture")?;
            let ctx = self.ctx.clone().context("d3d11 context")?;
            let src: ID3D11Resource = frame.texture.cast().context("texture -> resource")?;
            let dst: ID3D11Resource = staging.cast().context("staging -> resource")?;
            ctx.CopyResource(&dst, &src);
            let mut map = D3D11_MAPPED_SUBRESOURCE::default();
            ctx.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut map))
                .context("Map staging (bgra readback)")?;
            let pitch = map.RowPitch as usize;
            let h = self.height as usize;
            let base = map.pData as *const u8;
            let sws = self.ensure_sws(
                pixel_to_av(Pixel::BGRA),
                ffi::AVPixelFormat::AV_PIX_FMT_NV12,
                SWS_CS_ITU709,
            )?;
            let src_data: [*const u8; 4] = [base, ptr::null(), ptr::null(), ptr::null()];
            let src_stride: [c_int; 4] = [pitch as c_int, 0, 0, 0];
            let r = ffi::sws_scale(
                sws,
                src_data.as_ptr(),
                src_stride.as_ptr(),
                0,
                h as c_int,
                (*self.sw_frame.as_ptr()).data.as_ptr(),
                (*self.sw_frame.as_ptr()).linesize.as_ptr(),
            );
            ctx.Unmap(&staging, 0);
            if r < 0 {
                bail!("sws_scale BGRA→NV12 failed");
            }
            self.send(pts, idr)
        }
    }

    /// Read back a captured Rgb10a2 (BT.2020 PQ, R10G10B10A2) surface and swscale it to P010
    /// (BT.2020 PQ, limited range) — the HDR path when the capturer's video processor emitted its
    /// R10 shader output instead of P010. DXGI `R10G10B10A2_UNORM` (R in the low 10 bits, X2 alpha in
    /// the top 2) == FFmpeg `AV_PIX_FMT_X2BGR10LE`. UNTESTED on glass (no AMD/Intel Windows box).
    fn readback_rgb10(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
        // SAFETY: same shape as `readback_yuv`/`readback_bgra` — `ensure_staging` builds an
        // R10G10B10A2 STAGING texture on `frame.device` and caches its immediate context; `src`/`dst`
        // are that device's matching-format textures, so `CopyResource` on the single-threaded context
        // is valid. `Map(READ)` yields `base` valid for `pitch` × `h` rows. `ensure_sws` builds the
        // X2BGR10LE→P010 (BT.2020) context; `sws_scale` reads `h` rows of `pitch` bytes from `base`
        // (in-bounds) into the `sw_frame` P010 planes (`data`/`linesize`, allocated `width`×`height`).
        // `Unmap` pairs `Map`; `sws` is freed once in `Drop`. No aliasing between read and write.
        unsafe {
            self.ensure_staging(&frame.device, DXGI_FORMAT_R10G10B10A2_UNORM)?;
            let staging = self.staging.clone().context("staging texture")?;
            let ctx = self.ctx.clone().context("d3d11 context")?;
            let src: ID3D11Resource = frame.texture.cast().context("texture -> resource")?;
            let dst: ID3D11Resource = staging.cast().context("staging -> resource")?;
            ctx.CopyResource(&dst, &src);
            let mut map = D3D11_MAPPED_SUBRESOURCE::default();
            ctx.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut map))
                .context("Map staging (rgb10 readback)")?;
            let pitch = map.RowPitch as usize;
            let h = self.height as usize;
            let base = map.pData as *const u8;
            // RGB(BT.2020 PQ) → YUV(BT.2020 PQ): a matrix-only repack (same PQ transfer), full→limited.
            let sws = self.ensure_sws(
                ffi::AVPixelFormat::AV_PIX_FMT_X2BGR10LE,
                ffi::AVPixelFormat::AV_PIX_FMT_P010LE,
                SWS_CS_BT2020,
            )?;
            let src_data: [*const u8; 4] = [base, ptr::null(), ptr::null(), ptr::null()];
            let src_stride: [c_int; 4] = [pitch as c_int, 0, 0, 0];
            let r = ffi::sws_scale(
                sws,
                src_data.as_ptr(),
                src_stride.as_ptr(),
                0,
                h as c_int,
                (*self.sw_frame.as_ptr()).data.as_ptr(),
                (*self.sw_frame.as_ptr()).linesize.as_ptr(),
            );
            ctx.Unmap(&staging, 0);
            if r < 0 {
                bail!("sws_scale Rgb10a2→P010 failed");
            }
            self.send(pts, idr)
        }
    }

    /// CPU path: swscale a packed RGB/BGR CPU buffer to NV12, then send (8-bit only). Used when the
    /// capturer hands `FramePayload::Cpu` (DDA without the video-processor path).
    fn submit_cpu(&mut self, bytes: &[u8], format: PixelFormat, pts: i64, idr: bool) -> Result<()> {
        anyhow::ensure!(
            format == self.format,
            "captured format {format:?} != encoder source {:?}",
            self.format
        );
        if self.ten_bit {
            bail!("ffmpeg_win: CPU swscale path is 8-bit only");
        }
        let w = self.width as usize;
        let h = self.height as usize;
        let src_row = w * format.bytes_per_pixel();
        anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small");
        // SAFETY: `ensure_sws` lazily builds the (packed RGB/BGR)→NV12 context for this fixed src/dst
        // format pair. `src_data[0] = bytes.as_ptr()` with `src_stride[0] = src_row`; the `ensure!`
        // above guarantees `bytes` holds at least `src_row*h` bytes, so `sws_scale` reads `h` rows of
        // `src_row` bytes in-bounds and writes the `sw_frame` NV12 planes (`data`/`linesize`, allocated
        // `width`×`height`). `bytes` is borrowed for the call only and never aliases the owned
        // `sw_frame`. `send` then hands `sw_frame` to the encoder.
        unsafe {
            let sws = self.ensure_sws(
                pixel_to_av(sws_src(format)?),
                ffi::AVPixelFormat::AV_PIX_FMT_NV12,
                SWS_CS_ITU709,
            )?;
            let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
            let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
            if ffi::sws_scale(
                sws,
                src_data.as_ptr(),
                src_stride.as_ptr(),
                0,
                h as c_int,
                (*self.sw_frame.as_ptr()).data.as_ptr(),
                (*self.sw_frame.as_ptr()).linesize.as_ptr(),
            ) < 0
            {
                bail!("sws_scale RGB→NV12 failed");
            }
            self.send(pts, idr)
        }
    }

    /// Lazily build the swscale context (src → NV12/P010, limited range, the given colorspace). A
    /// SystemInner uses exactly one src→dst conversion for its lifetime (8-bit RGB→NV12 BT.709, or
    /// 10-bit RGB10→P010 BT.2020), so caching a single context is sound.
    ///
    /// Safe: every argument is a plain libav enum/int, and the context it caches belongs to `self`
    /// (an owned `AvSwsContext`, freed by its own drop). Returns the borrowed pointer for the
    /// caller's `sws_scale` — borrowed only, `self.sws` stays the owner.
    fn ensure_sws(
        &mut self,
        src_av: ffi::AVPixelFormat,
        dst_av: ffi::AVPixelFormat,
        cs: c_int,
    ) -> Result<*mut ffi::SwsContext> {
        if let Some(sws) = &self.sws {
            return Ok(sws.as_ptr());
        }
        // SAFETY: `sws_getContext` takes only scalars plus the documented "no filters, no params"
        // null trio, and returns an owned context or null — `from_raw` rejects the null, so
        // `sws_setColorspaceDetails` only ever sees a live one, and ownership passes to the
        // `AvSwsContext`. `sws_getCoefficients` returns a pointer into libav's own static tables,
        // valid for the process, and the call only reads it.
        let sws = unsafe {
            let raw = ffi::sws_getContext(
                self.width as c_int,
                self.height as c_int,
                src_av,
                self.width as c_int,
                self.height as c_int,
                dst_av,
                SWS_POINT,
                ptr::null_mut(),
                ptr::null_mut(),
                ptr::null(),
            );
            let Some(owned) = AvSwsContext::from_raw(raw) else {
                bail!("sws_getContext(RGB→YUV) failed");
            };
            // Source full-range RGB → destination limited-range YUV (matches the limited-range VUI
            // we signal). For RGB input the src coefficient table is unused; pass dst for both.
            let coeff = ffi::sws_getCoefficients(cs);
            ffi::sws_setColorspaceDetails(owned.as_ptr(), coeff, 1, coeff, 0, 0, 1 << 16, 1 << 16);
            owned
        };
        Ok(self.sws.insert(sws).as_ptr())
    }
}

// No `Drop` for `SystemInner`: `sw_frame` (`AvFrame`) and `sws` (`Option<AvSwsContext>`) free
// themselves, in field-declaration order — the same sw_frame-then-sws sequence the hand-written
// `Drop` performed, pinned by the offset_of assert at the struct.

// ---------------------------------------------------------------------------------------------
// Zero-copy D3D11 path (the AMF default; QSV opt-in — see `zerocopy_enabled`): share the capture
// device, pool D3D11 frames, copy the captured texture into a pooled slice, feed AMF directly /
// map to QSV. Falls back to the system path if the hw setup fails to open.
// ---------------------------------------------------------------------------------------------

struct D3d11Hw {
    // Declared frames-BEFORE-device on purpose: these drop in declaration order, reproducing what
    // the hand-written `Drop` this replaced did (the frames ctx holds its own ref on the device).
    // Do not reorder these two fields.
    frames_ref: AvBuffer,
    device_ref: AvBuffer,
}

impl D3d11Hw {
    /// Wrap the capturer's `ID3D11Device` as a D3D11VA hwdevice and build an NV12/P010 frames pool.
    /// Safe: like [`super::super::linux::VaapiHw::new`] and unlike its CUDA counterpart, this is
    /// handed no raw pointer — `&ID3D11Device` is a borrowed, reference-counted COM wrapper and the
    /// rest are scalars — so there is no caller contract; the `unsafe` below is the libav/D3D11 FFI.
    fn new(
        device: &ID3D11Device,
        sw_format: ffi::AVPixelFormat,
        bind_flags: u32,
        w: u32,
        h: u32,
        pool: c_int,
    ) -> Result<Self> {
        // Owned from the moment it exists: each `bail!` below drops what was built so far, so none
        // of the failure branches carry cleanup of their own.
        // SAFETY: `av_hwdevice_ctx_alloc` returns null — rejected by `AvBuffer::from_raw`, so the
        // `?` leaves before anything below runs — or a ref whose `data` libav has already
        // initialized as an `AVHWDeviceContext`; for a D3D11VA device that context's `hwctx` is an
        // `AVD3D11VADeviceContext`, so `d11` addresses a live, correctly-typed struct.
        let (device_ref, d11) = unsafe {
            let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
                ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA,
            ))
            .context("av_hwdevice_ctx_alloc(D3D11VA) failed")?;
            let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
            let d11 = (*dev_ctx).hwctx as *mut AVD3D11VADeviceContext;
            (device_ref, d11)
        };

        // Turn on D3D11 multithread protection before libav sees the device.
        //
        // libav does this itself in `d3d11va_device_create` — but only there. We take the OTHER
        // path (`d3d11va_device_init`, because we supply the capturer's device), which does not,
        // and the omission bites twice:
        //
        //  * QSV. `av_hwdevice_ctx_create_derived(QSV <- D3D11VA)` ends in
        //    `MFXVideoCORE_SetHandle`, and MFX rejects a device that is not multithread-protected
        //    with `MFX_ERR_UNDEFINED_BEHAVIOR (-16)` — logged only as "Error setting child device
        //    handle", which names neither the cause nor the cure. Measured on Intel UHD 750 /
        //    FFmpeg 7.1.5+libvpl: identical device, protection off -> derive fails; protection on
        //    -> derive succeeds. `D3D11_CREATE_DEVICE_VIDEO_SUPPORT` makes no difference either way.
        //
        //  * AMF, which is the DEFAULT path and was already shipping. We deliberately leave
        //    `lock`/`unlock` null so libav installs its `d3d11va_default_lock`, and that lock is
        //    `ID3D11Multithread::Enter`/`Leave` — which are documented no-ops while protection is
        //    off. So the lock libav installs to serialise our capture thread against its encode
        //    thread has been doing nothing at all. It only starts working from here.
        //
        // Idempotent, and safe to apply to a device the capturer owns: it only enables the
        // device's internal critical section (`was` is the previous state, reported once at debug).
        match device.cast::<ID3D11Multithread>() {
            Ok(mt) => {
                // SAFETY: a COM call on the live `ID3D11Multithread` just obtained by a checked
                // `cast` of the borrowed device; it takes a BOOL and returns the previous state.
                let was = unsafe { mt.SetMultithreadProtected(true) };
                tracing::debug!(
                    previously_protected = was.as_bool(),
                    "D3D11 multithread protection enabled for the libav hwdevice"
                );
            }
            // Pre-11.1 runtimes have no ID3D11Multithread. Nothing to enable, so carry on and let
            // the QSV derive fail with its own message rather than failing capture here.
            Err(e) => tracing::warn!(
                error = %e,
                "no ID3D11Multithread on this device — QSV zero-copy will not derive"
            ),
        }

        // Share the capture device. FFmpeg's d3d11va teardown Releases `device`, so hand it an owned
        // reference (clone = AddRef, forget = don't Release ours). init() fills
        // device_context / video_device / video_context / the default lock from a non-null device.
        std::mem::forget(device.clone());
        // SAFETY: `d11` is the live `AVD3D11VADeviceContext` from above, so storing the device
        // pointer is an in-bounds field write; the `forget(clone())` on the line above is what
        // makes that pointer an OWNED reference, matching the Release libav does at teardown.
        // `av_hwdevice_ctx_init` then reads that field, which is why the store precedes it.
        let r = unsafe {
            (*d11).device = device.as_raw();
            ffi::av_hwdevice_ctx_init(device_ref.as_ptr())
        };
        if r < 0 {
            bail!("av_hwdevice_ctx_init(D3D11VA) failed ({r})");
        }

        // SAFETY: same shape one level up — `av_hwframe_ctx_alloc` takes the live, now-initialized
        // device ref and returns null (rejected by `from_raw`, so the `?` leaves before the writes)
        // or a ref whose `data` is a live `AVHWFramesContext` whose `hwctx` is an
        // `AVD3D11VAFramesContext`. Every store is an in-bounds field write on those, all done
        // before `av_hwframe_ctx_init` reads them.
        let frames_ref = unsafe {
            let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
                .context("av_hwframe_ctx_alloc(D3D11VA) failed")?;
            let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
            (*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_D3D11;
            (*fc).sw_format = sw_format;
            (*fc).width = w as c_int;
            (*fc).height = h as c_int;
            (*fc).initial_pool_size = pool;
            let f11 = (*fc).hwctx as *mut AVD3D11VAFramesContext;
            (*f11).bind_flags = bind_flags;
            let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
            if r < 0 {
                bail!("av_hwframe_ctx_init(D3D11VA) failed ({r})");
            }
            frames_ref
        };
        Ok(D3d11Hw {
            frames_ref,
            device_ref,
        })
    }
}

// No `Drop` for `D3d11Hw`: each `AvBuffer` field unrefs itself, in declaration order (frames, then
// device — see the field comment). The hand-written unref pair this replaced had to be kept in sync
// with every failure branch in `new`; now there is one unref path per ref and none can skip it.

struct ZeroCopyInner {
    vendor: WinVendor,
    /// QSV only: the QSV device + frames ctx derived from the D3D11VA ones (the encoder's real
    /// input). `None` for AMF, which takes the D3D11 frames directly — the nullable raw pointers
    /// these replaced said the same thing, but only in a comment.
    ///
    /// FIELD ORDER IS LOAD-BEARING: frames before device, and BOTH before `enc`/`hw`. Fields drop
    /// in declaration order, and that sequence reproduces the hand-written `Drop` this replaced,
    /// which ran ahead of every field and so released the derived QSV pair before the encoder and
    /// the D3D11 refs. Everything holds its own reference, so refcounting makes any order sound;
    /// the ordering is pinned so a reorder cannot quietly change what ships.
    qsv_frames: Option<AvBuffer>,
    /// Unlike `qsv_frames` (which the send path reads to tag each mapped frame), this one is held
    /// purely as an owner: the frames ctx and the encoder each took their own ref, so nothing reads
    /// it again — it exists so the QSV device outlives both and is unref'd exactly once. Same
    /// reasoning as the decoders' `hw_device`: removing the field would free the device early, and
    /// an underscore name would hide what it holds.
    #[allow(dead_code)]
    qsv_device: Option<AvBuffer>,
    enc: encoder::video::Encoder,
    hw: D3d11Hw,
    ctx: ID3D11DeviceContext,
    /// The pool's fixed sw_format (NV12 8-bit / P010 10-bit). A captured frame whose format differs
    /// (the capturer's video-processor fell back to Bgra/Rgb10a2) cannot be CopySubresourceRegion'd
    /// into this pool (format-group mismatch → UB), so the caller drops to the system path instead.
    pool_format: PixelFormat,
}

impl ZeroCopyInner {
    #[allow(clippy::too_many_arguments)]
    fn open(
        vendor: WinVendor,
        codec: Codec,
        format: PixelFormat,
        width: u32,
        height: u32,
        fps: u32,
        bitrate_bps: u64,
        bit_depth: u8,
        device: &ID3D11Device,
    ) -> Result<Self> {
        let ten_bit = crate::ten_bit_input(format, bit_depth);
        let sw_av = if ten_bit {
            ffi::AVPixelFormat::AV_PIX_FMT_P010LE
        } else {
            ffi::AVPixelFormat::AV_PIX_FMT_NV12
        };
        let pool_format = if ten_bit {
            PixelFormat::P010
        } else {
            PixelFormat::Nv12
        };
        let bind_flags = pool_bind_flags(vendor);
        const POOL: c_int = 8;
        // SAFETY: `D3d11Hw::new` wraps the capturer's `device` as a D3D11VA hwdevice (handing FFmpeg
        // an owned AddRef of it, balanced by FFmpeg's teardown Release) and returns an owned
        // frames_ref/device_ref pair. For QSV, `av_hwdevice_ctx_create_derived` /
        // `av_hwframe_ctx_create_derived` fill their null-initialised out-params only on success
        // (`r >= 0` checked) and each result is taken into an `AvBuffer` immediately. From there
        // every handle in this function is owned by a local, so each early `bail!`/`?` releases
        // exactly what exists at that point, in reverse order — there is no cleanup code on any
        // failure branch to keep in step. `open_win_encoder` takes its OWN refs of the dev/frames
        // pointers it is handed, so lending them via `as_ptr()` transfers nothing; on success the
        // owners move into `ZeroCopyInner` and are released by its field drops. Every `AVBufferRef`
        // is still unref'd exactly once on every path — the difference is that it is now the type
        // system enforcing it rather than a comment.
        unsafe {
            let hw = D3d11Hw::new(device, sw_av, bind_flags, width, height, POOL)?;
            // Own the derived QSV pair (or nothing, on AMF). Keeping ownership here and deriving the
            // encoder's pointers from it below is the whole point: the tuple this replaced handed
            // the SAME two pointers out twice — once as the encoder's dev/frames args and once as
            // the pair moved into `Self` — which is fine for raw pointers and would be two owners
            // for `AvBuffer`.
            let (qsv_frames, qsv_device) = match vendor {
                WinVendor::Amf => (None, None),
                WinVendor::Qsv => {
                    // Derive a QSV device that SHARES the D3D11 device, and a QSV frames ctx derived
                    // from the D3D11 frames pool (auto-mapped 1:1). The encoder takes AV_PIX_FMT_QSV.
                    let mut qsv_device: *mut ffi::AVBufferRef = ptr::null_mut();
                    let r = ffi::av_hwdevice_ctx_create_derived(
                        &mut qsv_device,
                        ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_QSV,
                        hw.device_ref.as_ptr(),
                        0,
                    );
                    if r < 0 {
                        bail!("derive QSV device from D3D11VA: {}", ffmpeg::Error::from(r));
                    }
                    let qsv_device = AvBuffer::from_raw(qsv_device)
                        .context("av_hwdevice_ctx_create_derived(QSV) gave no device")?;
                    let mut qsv_frames: *mut ffi::AVBufferRef = ptr::null_mut();
                    let r = ffi::av_hwframe_ctx_create_derived(
                        &mut qsv_frames,
                        ffi::AVPixelFormat::AV_PIX_FMT_QSV,
                        qsv_device.as_ptr(),
                        hw.frames_ref.as_ptr(),
                        ffi::AV_HWFRAME_MAP_DIRECT as c_int,
                    );
                    if r < 0 {
                        // `qsv_device` drops here — the hand-written unref this replaced was the
                        // single easiest line in the function to forget.
                        bail!("derive QSV frames from D3D11VA: {}", ffmpeg::Error::from(r));
                    }
                    let qsv_frames = AvBuffer::from_raw(qsv_frames)
                        .context("av_hwframe_ctx_create_derived(QSV) gave no frames ctx")?;
                    (Some(qsv_frames), Some(qsv_device))
                }
            };
            // BORROWED views for the encoder — `open_win_encoder` takes its own refs of whatever it
            // is handed, so ownership stays with `qsv_*`/`hw` either way.
            let (pix_fmt, dev_ref, frames_ref) = match (&qsv_device, &qsv_frames) {
                (Some(d), Some(f)) => (ffi::AVPixelFormat::AV_PIX_FMT_QSV, d.as_ptr(), f.as_ptr()),
                _ => (
                    ffi::AVPixelFormat::AV_PIX_FMT_D3D11,
                    hw.device_ref.as_ptr(),
                    hw.frames_ref.as_ptr(),
                ),
            };
            // `?` is enough now: on failure `qsv_frames`/`qsv_device` and `hw` all drop on the way
            // out, which is what the hand-written null-checked unref pair here used to do.
            let enc = open_win_encoder(
                vendor,
                codec,
                width,
                height,
                fps,
                bitrate_bps,
                pix_fmt,
                sw_av,
                ten_bit,
                dev_ref,
                frames_ref,
            )?;
            tracing::info!(
                encoder = vendor.encoder_name(codec),
                "{} encode active ({width}x{height}@{fps}, zero-copy D3D11 {} path)",
                vendor.label(),
                if ten_bit { "P010" } else { "NV12" }
            );
            Ok(ZeroCopyInner {
                vendor,
                qsv_frames,
                qsv_device,
                enc,
                hw,
                ctx: immediate_context(device),
                pool_format,
            })
        }
    }

    fn submit(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
        // SAFETY: `d3d`/`qsv` are owned `AvFrame`s, so EVERY exit — including the three `?` exits
        // between the pool pull and the send, which as hand-placed frees previously leaked the
        // frame plus one of the POOL-sized hwframe surfaces per failure (eight failures wedged
        // the encoder permanently) — unrefs the pooled surface back to the pool. `(*d3d).data[0]`
        // is the pool's texture-array and `data[1]` the array index; `from_raw_borrowed` borrows
        // that `ID3D11Texture2D` WITHOUT taking ownership (no Release — the frame owns it) and is
        // null-checked. `src` (the captured texture) and `dst` (the pooled slice) live on the
        // SAME D3D11 device wrapped by `self.hw`, and the caller guarantees `captured.format ==
        // pool_format` before calling, so `CopySubresourceRegion(dst, dst_index, .., src, 0, ..)`
        // on the single-threaded immediate context `self.ctx` is a valid same-format GPU copy.
        // For QSV the mapped `qsv` frame's `hw_frames_ctx` takes an `av_buffer_ref` of
        // `self.qsv_frames`; its drop at the end of the arm releases that ref at the same point
        // the hand-written free did. `avcodec_send_frame` only internally refs the input frame,
        // so the drops are the sole owning frees — no leak, no double-free, no use-after-free.
        unsafe {
            // Pull a pooled D3D11 surface; its data[0] is the pool's texture-ARRAY, data[1] the slice.
            let d3d = AvFrame::alloc().context("av_frame_alloc(d3d11) failed")?;
            let r = ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), d3d.as_ptr(), 0);
            if r < 0 {
                bail!("av_hwframe_get_buffer(D3D11) failed ({r})");
            }
            let dst_ptr = (*d3d.as_ptr()).data[0] as *mut c_void;
            let dst_index = (*d3d.as_ptr()).data[1] as usize as u32;
            let dst_tex = ID3D11Texture2D::from_raw_borrowed(&dst_ptr)
                .ok_or_else(|| anyhow!("pooled D3D11 frame has null texture"))?;
            // GPU-local copy of the captured slice into the pooled array slice (like NVENC's CUDA
            // device→device copy). Subresource = arrayIndex (MipLevels=1).
            let src: ID3D11Resource = frame.texture.cast().context("texture -> resource")?;
            let dst: ID3D11Resource = dst_tex.cast().context("pooled texture -> resource")?;
            self.ctx
                .CopySubresourceRegion(&dst, dst_index, 0, 0, 0, &src, 0, None);

            (*d3d.as_ptr()).pts = pts;
            (*d3d.as_ptr()).pict_type = if idr {
                ffi::AVPictureType::AV_PICTURE_TYPE_I
            } else {
                ffi::AVPictureType::AV_PICTURE_TYPE_NONE
            };

            let send = match self.vendor {
                WinVendor::Amf => ffi::avcodec_send_frame(self.enc.as_mut_ptr(), d3d.as_ptr()),
                WinVendor::Qsv => {
                    // Map the D3D11 frame to a QSV surface (1:1, no copy), then send the mapped frame.
                    let qsv = AvFrame::alloc().context("av_frame_alloc(qsv) failed")?;
                    // Always `Some` on this arm — `open` fills the pair for `WinVendor::Qsv` and
                    // leaves it `None` only for AMF — but say so with a bail rather than an unwrap,
                    // matching the null check above it. The `Option` is what the raw pointer's
                    // "null means AMF" convention was already encoding.
                    let Some(qsv_frames) = self.qsv_frames.as_ref() else {
                        bail!("QSV send path without a derived QSV frames context");
                    };
                    (*qsv.as_ptr()).format = ffi::AVPixelFormat::AV_PIX_FMT_QSV as c_int;
                    (*qsv.as_ptr()).hw_frames_ctx = ffi::av_buffer_ref(qsv_frames.as_ptr());
                    // The map flags are a bindgen enum (no BitOr) — cast each to int before OR-ing.
                    let r = ffi::av_hwframe_map(
                        qsv.as_ptr(),
                        d3d.as_ptr(),
                        ffi::AV_HWFRAME_MAP_DIRECT as c_int | ffi::AV_HWFRAME_MAP_READ as c_int,
                    );
                    if r < 0 {
                        bail!("av_hwframe_map(D3D11→QSV) failed ({r})");
                    }
                    (*qsv.as_ptr()).pts = pts;
                    (*qsv.as_ptr()).pict_type = (*d3d.as_ptr()).pict_type;
                    ffi::avcodec_send_frame(self.enc.as_mut_ptr(), qsv.as_ptr())
                    // `qsv` drops here — releasing the mapped frame and its frames-ctx ref at the
                    // same point the hand-written `av_frame_free(&mut qsv)` did.
                }
            };
            if send < 0 {
                bail!(
                    "avcodec_send_frame({}) failed ({send})",
                    self.vendor.label()
                );
            }
            // `d3d` drops here (and on every early exit above), returning the pooled surface.
        }
        Ok(())
    }
}

// No `Drop` for `ZeroCopyInner`: the two `Option<AvBuffer>`s unref themselves when present and do
// nothing when `None` (AMF), which is what the hand-written null checks amounted to. Field order
// (see the struct) keeps the release sequence identical: QSV frames, QSV device, then the encoder's
// AddRef'd copies via `enc`, then the D3D11 pair via `hw`.

// ---------------------------------------------------------------------------------------------

enum Inner {
    System(SystemInner),
    ZeroCopy(ZeroCopyInner),
}

pub struct FfmpegWinEncoder {
    vendor: WinVendor,
    codec: Codec,
    format: PixelFormat,
    width: u32,
    height: u32,
    fps: u32,
    bitrate_bps: u64,
    bit_depth: u8,
    /// Built lazily from the first frame (system readback vs zero-copy D3D11).
    inner: Option<Inner>,
    /// Raw `ID3D11Device` pointer the live inner is bound to — re-init on change (the capturer
    /// recreates its device across secure-desktop / HDR / resize transitions, like NVENC tracks).
    bound_device: isize,
    frame_idx: i64,
    force_kf: bool,
    /// Frames sent to libavcodec whose AUs haven't been received yet. `poll` blocks (bounded)
    /// while this is non-zero — see the poll-contract note on [`Encoder::poll`] below.
    in_flight: usize,
}

// Raw FFI pointers + COM objects; the encoder lives on a single thread (same contract as NVENC/VAAPI).
// SAFETY: `FfmpegWinEncoder` owns raw libav pointers (`AVFrame`/`SwsContext`/`AVBufferRef`) and
// windows-rs COM handles (`ID3D11Device`/`ID3D11DeviceContext`/textures) that are not auto-`Send`. The
// session creates the encoder, drives `submit`/`poll`/`flush`, and drops it all on one dedicated encode
// thread; it is never shared by reference across threads, and the D3D11 immediate context is only ever
// touched from that thread. The only cross-thread action is the initial move to the encode thread,
// after which every interior pointer/COM ref is used single-threaded — the same contract the
// NVENC/VAAPI encoders rely on. No interior state is accessed concurrently.
unsafe impl Send for FfmpegWinEncoder {}

impl FfmpegWinEncoder {
    #[allow(clippy::too_many_arguments)]
    #[allow(clippy::too_many_arguments)]
    pub fn open(
        vendor: WinVendor,
        codec: Codec,
        format: PixelFormat,
        width: u32,
        height: u32,
        fps: u32,
        bitrate_bps: u64,
        bit_depth: u8,
        chroma: ChromaFormat,
    ) -> Result<Self> {
        // AMF/QSV 4:4:4 is deferred (see `probe_can_encode_444`): no validated AMD/Intel Windows
        // hardware in the lab, and the AMF/QSV HEVC 4:4:4 profile/format incantations are vendor- and
        // driver-specific (a wrong profile silently encodes 4:2:0). The probe returns false so the host
        // never negotiates 4:4:4 for an AMF/QSV session; if a request slips through, fall back to 4:2:0.
        if chroma.is_444() {
            tracing::warn!("AMF/QSV 4:4:4 encode not implemented — encoding 4:2:0");
        }
        ffmpeg::init().context("ffmpeg init")?;
        if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
            // SAFETY: `ffmpeg::init()` ran on the line above, so libav is initialised; `av_log_set_level`
            // is a global scalar setter with no pointer arguments.
            unsafe { ffi::av_log_set_level(48) };
        }
        // Make sure the encoder name exists in this libavcodec build up front (clear error vs a
        // first-frame failure).
        let name = vendor.encoder_name(codec);
        if encoder::find_by_name(name).is_none() {
            bail!(
                "{name} not built into libavcodec (this FFmpeg lacks the {} encoder)",
                vendor.label()
            );
        }
        Ok(FfmpegWinEncoder {
            vendor,
            codec,
            format,
            width,
            height,
            fps,
            bitrate_bps,
            bit_depth,
            inner: None,
            bound_device: 0,
            frame_idx: 0,
            force_kf: false,
            in_flight: 0,
        })
    }

    /// Build (or rebuild) the inner for a D3D11 frame, picking zero-copy or system. Zero-copy
    /// failures fall back to the system path so a session is never lost to the untested hw path. The
    /// device is re-bound on change (the capturer recreates it across secure-desktop / HDR / resize).
    fn ensure_inner_d3d11(&mut self, device: &ID3D11Device) -> Result<()> {
        let dev_raw = device.as_raw() as isize;
        if self.inner.is_some() && self.bound_device == dev_raw {
            return Ok(());
        }
        self.inner = None;
        self.bound_device = dev_raw;
        let inner = if zerocopy_enabled(self.vendor) {
            match ZeroCopyInner::open(
                self.vendor,
                self.codec,
                self.format,
                self.width,
                self.height,
                self.fps,
                self.bitrate_bps,
                self.bit_depth,
                device,
            ) {
                Ok(zc) => Inner::ZeroCopy(zc),
                Err(e) => {
                    tracing::warn!(
                        error = %format!("{e:#}"),
                        "{} zero-copy D3D11 setup failed — falling back to system-memory readback",
                        self.vendor.label()
                    );
                    Inner::System(self.open_system()?)
                }
            }
        } else {
            Inner::System(self.open_system()?)
        };
        self.inner = Some(inner);
        Ok(())
    }

    fn open_system(&self) -> Result<SystemInner> {
        SystemInner::open(
            self.vendor,
            self.codec,
            self.format,
            self.width,
            self.height,
            self.fps,
            self.bitrate_bps,
            self.bit_depth,
        )
    }
}

impl Encoder for FfmpegWinEncoder {
    fn submit(&mut self, captured: &CapturedFrame) -> Result<()> {
        anyhow::ensure!(
            captured.width == self.width && captured.height == self.height,
            "captured frame {}x{} != encoder {}x{}",
            captured.width,
            captured.height,
            self.width,
            self.height
        );
        let pts = self.frame_idx;
        self.frame_idx += 1;
        let idr = self.force_kf;
        self.force_kf = false;
        let submitted = match &captured.payload {
            FramePayload::D3d11(f) => {
                self.ensure_inner_d3d11(&f.device)?;
                // If zero-copy is active but the capturer fell back to a format the NV12/P010 pool
                // can't accept (no video processor → Bgra/Rgb10a2), a CopySubresourceRegion into the
                // pool would be a format-group mismatch (UB / device removal). Drop to the system
                // readback path, which handles every captured format.
                let pool_mismatch = matches!(
                    &self.inner,
                    Some(Inner::ZeroCopy(zc)) if captured.format != zc.pool_format
                );
                if pool_mismatch {
                    tracing::warn!(
                        captured = ?captured.format,
                        "{} zero-copy pool format mismatch (capturer video-processor fallback) — \
                         switching to system-memory readback",
                        self.vendor.label()
                    );
                    self.inner = Some(Inner::System(self.open_system()?));
                }
                match self.inner.as_mut().unwrap() {
                    Inner::ZeroCopy(zc) => zc.submit(f, pts, idr),
                    Inner::System(s) => s.submit_d3d11(f, captured.format, pts, idr),
                }
            }
            FramePayload::Cpu(bytes) => {
                // DDA-without-video-processor hands CPU BGRA; build a system inner and swscale it.
                if self.inner.is_none() {
                    self.inner = Some(Inner::System(self.open_system()?));
                }
                match self.inner.as_mut().unwrap() {
                    Inner::System(s) => s.submit_cpu(bytes, captured.format, pts, idr),
                    Inner::ZeroCopy(_) => {
                        bail!(
                            "{} encoder built for D3D11 got a CPU frame",
                            self.vendor.label()
                        )
                    }
                }
            }
        };
        if submitted.is_ok() {
            self.in_flight += 1;
        }
        submitted
    }

    fn request_keyframe(&mut self) {
        self.force_kf = true;
    }

    /// Encode-stall recovery: drop the wedged libavcodec encoder (its `Drop` releases the AMF/QSV
    /// runtime state) and let the next `submit` rebuild it lazily on the current device, exactly
    /// like first-frame bring-up. The owed AUs are forfeited (`in_flight` zeroed) and the rebuilt
    /// encoder's first frame is forced IDR so the client resyncs immediately.
    fn reset(&mut self) -> bool {
        self.inner = None;
        self.bound_device = 0;
        self.in_flight = 0;
        self.force_kf = true;
        true
    }

    /// Poll for the next finished AU (single non-blocking `receive_packet`).
    ///
    /// libavcodec's `hevc_amf`/`av1_amf` wrapper holds ~2 frames before releasing the oldest
    /// (it needs frame N+2 submitted to flush N), so the encode→retrieve latency floors at
    /// **~2 frame periods** — measured dead-stable at 36 ms p50 for 720p60 on the Ryzen 7000
    /// iGPU across depth 1/2, every `usage` preset, and any spin (a spin between submits provably
    /// never produces the owed AU — verified with a 150 ms cap pegging at exactly 150 ms). So the
    /// buffer is inherent to the libavcodec path, NOT host scheduling: the real fix is a direct
    /// AMF SDK encoder (the AMF analogue of `encode/windows/nvenc.rs`, whose delay=0 gives NVENC
    /// its ~1–2 ms) — tracked as the next AMD latency lever. `PUNKTFUNK_FFWIN_POLL_MS` keeps a
    /// bounded spin available for a future VCN/driver where the AU can land mid-spin (0 = off,
    /// the default and correct choice on measured hardware).
    fn poll(&mut self) -> Result<Option<EncodedFrame>> {
        let fps = self.fps;
        let enc = match &mut self.inner {
            Some(Inner::System(s)) => &mut s.enc,
            Some(Inner::ZeroCopy(z)) => &mut z.enc,
            None => return Ok(None),
        };
        let cap_us = poll_spin_cap_us();
        let deadline = (cap_us > 0 && self.in_flight > 0)
            .then(|| std::time::Instant::now() + std::time::Duration::from_micros(cap_us));
        loop {
            match poll_encoder(enc, fps)? {
                PollOutcome::Packet(au) => {
                    self.in_flight = self.in_flight.saturating_sub(1);
                    return Ok(Some(au));
                }
                PollOutcome::Eof => {
                    self.in_flight = 0; // flushed: nothing further is owed
                    return Ok(None);
                }
                PollOutcome::Again => match deadline {
                    Some(d) if std::time::Instant::now() < d => {
                        std::thread::sleep(std::time::Duration::from_micros(250));
                    }
                    _ => return Ok(None),
                },
            }
        }
    }

    fn flush(&mut self) -> Result<()> {
        match &mut self.inner {
            Some(Inner::System(s)) => s.enc.send_eof().context("send_eof")?,
            Some(Inner::ZeroCopy(z)) => z.enc.send_eof().context("send_eof")?,
            None => {}
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Pick the Intel adapter, or any hardware D3D11 adapter, for the probes below.
    ///
    /// Not `EnumAdapters1(0)`: a punktfunk host box also enumerates our own virtual-display adapter,
    /// and "Microsoft Basic Render Driver" (vendor 0x1414) is the software rasterizer, which has no
    /// video engine at all.
    ///
    /// Safe: `prefer_vendor` is a plain id and every DXGI object is created here and owned by the
    /// returned device; the old `# Safety` section described the body, not a caller obligation.
    #[cfg(test)]
    fn test_hw_device(prefer_vendor: u32) -> Option<ID3D11Device> {
        use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1};
        // SAFETY: DXGI factory/adapter enumeration over owned locals — the factory is created here,
        // each adapter it yields owns its own COM reference, and every call is `.ok()`-checked
        // before use. `GetDesc1` fills a fully-initialized stack descriptor.
        let (factory, mut preferred, mut fallback): (IDXGIFactory1, _, _) =
            (unsafe { CreateDXGIFactory1() }.ok()?, None, None);
        for i in 0.. {
            // SAFETY: a COM call on the live `factory` created above; it takes an index and
            // yields an owned adapter, and the `Ok` binding is what proves one came back.
            let Ok(adapter) = (unsafe { factory.EnumAdapters1(i) }) else {
                break; // DXGI_ERROR_NOT_FOUND — end of the list
            };
            // SAFETY: a COM call on the adapter just enumerated, filling a fully-initialized
            // stack descriptor it returns by value.
            let Ok(desc) = (unsafe { adapter.GetDesc1() }) else {
                continue;
            };
            let name = String::from_utf16_lossy(&desc.Description)
                .trim_end_matches('\0')
                .to_string();
            eprintln!("adapter {i}: vendor={:#06x} {name}", desc.VendorId);
            if desc.VendorId == prefer_vendor && preferred.is_none() {
                preferred = Some(adapter);
            } else if desc.VendorId != 0x1414 && fallback.is_none() {
                fallback = Some(adapter);
            }
        }
        let adapter = preferred.or(fallback)?;
        // SAFETY: `make_device` requires a live `IDXGIAdapter1`; `adapter` is one of the adapters
        // enumerated above, still owned here and borrowed only for this synchronous call.
        unsafe { pf_frame::dxgi::make_device(&adapter) }
            .ok()
            .map(|(d, _c)| d)
    }

    /// Construct/drop `D3d11Hw` repeatedly on real silicon — the D3D11VA half of the RAII change,
    /// and the half that IS reachable on any GPU.
    ///
    /// `D3d11Hw` owns a hwdevice + frames-pool pair through `AvBuffer`, and it is shared by both
    /// Windows zero-copy vendors, so this covers the AMF path's ownership too without needing AMD
    /// hardware. Looping is the point, as with `cuda_hw_alloc_drop_cycles`: a double-unref aborts in
    /// the CRT, a missed one leaks a device and a pool of eight surfaces per iteration.
    ///
    /// `#[ignore]`d (needs a real D3D11 GPU):
    /// `cargo test -p pf-encode --features amf-qsv d3d11hw_alloc_drop_cycles -- --ignored --nocapture`
    #[test]
    #[ignore = "needs a real D3D11 GPU (run on a GPU host, not the build box)"]
    fn d3d11hw_alloc_drop_cycles() {
        let device = test_hw_device(0x8086).expect("a hardware D3D11 adapter");
        for i in 0..8 {
            // `D3d11Hw::new` is safe now; it still needs libav initialised, which the ffmpeg-next
            // crate does statically on first use here. NV12 at 640x480 with an 8-surface pool are
            // valid pool parameters. The handle drops at the end of each iteration — that release
            // is what is under test.
            let hw = D3d11Hw::new(
                &device,
                ffi::AVPixelFormat::AV_PIX_FMT_NV12,
                pool_bind_flags(WinVendor::Amf),
                640,
                480,
                8,
            )
            .unwrap_or_else(|e| panic!("D3d11Hw::new failed on iteration {i}: {e:#}"));
            assert!(!hw.device_ref.as_ptr().is_null(), "device ref went null");
            assert!(!hw.frames_ref.as_ptr().is_null(), "frames ref went null");
        }
        eprintln!("8 D3d11Hw alloc/drop cycles completed without abort");
    }

    /// Construct/drop `ZeroCopyInner` on the QSV path, repeatedly, on real Intel silicon.
    ///
    /// This is the one path in the crate where ownership was genuinely ambiguous: `open` builds a
    /// `D3d11Hw` (D3D11VA device + frames pair) and then DERIVES a QSV device + frames ctx from it,
    /// and the tuple it used to return handed those two derived pointers out **twice** — once as
    /// the encoder's `dev_ref`/`frames_ref`, once as the pair moved into `Self`. Harmless while they
    /// were raw pointers; two owners once they are `AvBuffer`. Looping construct/drop is what tells
    /// the difference apart: a double-unref aborts inside the CRT, a missed one leaks an Intel
    /// device per session.
    ///
    /// Nothing else reaches it. `zerocopy_enabled` defaults QSV **off**, so the normal encode path
    /// never builds one on Intel, and the native VPL backend (`enc/windows/qsv.rs`) supersedes this
    /// whole file unless `PUNKTFUNK_QSV_FFMPEG=1`. Calling `open` directly sidesteps both gates so
    /// the ownership itself is what gets exercised.
    ///
    /// This test is also the regression guard for the D3D11 multithread-protection fix in
    /// `D3d11Hw::new`. Without it the QSV derive dies in `MFXVideoCORE_SetHandle` with
    /// `MFX_ERR_UNDEFINED_BEHAVIOR (-16)`, surfacing only as libav's "Error setting child device
    /// handle" — a message that names neither the cause nor the cure. If this starts failing that
    /// way again, look there first.
    ///
    /// `#[ignore]`d (needs a real Intel QSV device):
    /// `cargo test -p pf-encode --features amf-qsv zerocopy_qsv_alloc_drop_cycles -- --ignored --nocapture`
    #[test]
    #[ignore = "needs a real Intel QSV device (run on an Intel host, not the build box)"]
    fn zerocopy_qsv_alloc_drop_cycles() {
        let device = test_hw_device(0x8086).expect("an Intel D3D11 adapter");
        for i in 0..8 {
            let zc = ZeroCopyInner::open(
                WinVendor::Qsv,
                Codec::H264,
                PixelFormat::Bgrx,
                640,
                480,
                30,
                8_000_000,
                8,
                &device,
            )
            .unwrap_or_else(|e| panic!("ZeroCopyInner::open(QSV) failed on iteration {i}: {e:#}"));
            // The QSV arm must have derived BOTH halves — an `Option` that came back `None` here
            // would mean the AMF branch was taken and the derived-pair ownership never ran.
            assert!(zc.qsv_frames.is_some(), "QSV path derived no frames ctx");
            assert!(zc.qsv_device.is_some(), "QSV path derived no device");
        }
        eprintln!("8 ZeroCopyInner(QSV) alloc/drop cycles completed without abort");
    }

    /// Zero-copy default matrix: the operator override wins in both directions; unset resolves
    /// AMF on (on-glass validated) and QSV off (opt-in until validated on Intel glass — the
    /// probe-never-assume rule).
    #[test]
    fn zerocopy_default_is_per_vendor_and_override_wins() {
        assert!(zerocopy_active(None, WinVendor::Amf));
        assert!(!zerocopy_active(None, WinVendor::Qsv));
        for vendor in [WinVendor::Amf, WinVendor::Qsv] {
            assert!(zerocopy_active(Some(true), vendor));
            assert!(!zerocopy_active(Some(false), vendor));
        }
    }

    /// `PUNKTFUNK_FFWIN_POLL_MS` grammar: default 0 (no spin), verbatim ms → µs inside the clamp,
    /// and the clamp applies BEFORE the µs conversion — the slipped-digit value that used to be a
    /// 27.7-hour spin resolves to the 1 s cap, not a wedged encode thread.
    #[test]
    fn poll_spin_cap_clamps_before_the_us_conversion() {
        assert_eq!(parse_poll_spin_cap_us(None), 0);
        assert_eq!(parse_poll_spin_cap_us(Some("0")), 0);
        assert_eq!(parse_poll_spin_cap_us(Some("5")), 5_000);
        assert_eq!(parse_poll_spin_cap_us(Some(" 12 ")), 12_000);
        assert_eq!(
            parse_poll_spin_cap_us(Some("100000000")),
            MAX_POLL_SPIN_MS * 1000
        );
        assert_eq!(
            parse_poll_spin_cap_us(Some(&u64::MAX.to_string())),
            MAX_POLL_SPIN_MS * 1000
        );
        assert_eq!(parse_poll_spin_cap_us(Some("junk")), 0);
        assert_eq!(parse_poll_spin_cap_us(Some("-1")), 0);
    }

    /// The swscale source map: packed RGB/BGR converts; every YUV/10-bit layout is refused (the
    /// swscale lane is the 8-bit BGRA fallback, not a general converter — the Linux HDR formats
    /// are listed explicitly so a `PixelFormat` addition re-breaks the match on purpose).
    #[test]
    fn sws_src_accepts_packed_rgb_only() {
        assert_eq!(sws_src(PixelFormat::Bgrx).unwrap(), Pixel::BGRZ);
        assert_eq!(sws_src(PixelFormat::Rgbx).unwrap(), Pixel::RGBZ);
        assert_eq!(sws_src(PixelFormat::Bgra).unwrap(), Pixel::BGRA);
        assert_eq!(sws_src(PixelFormat::Rgba).unwrap(), Pixel::RGBA);
        assert_eq!(sws_src(PixelFormat::Rgb).unwrap(), Pixel::RGB24);
        assert_eq!(sws_src(PixelFormat::Bgr).unwrap(), Pixel::BGR24);
        for f in [
            PixelFormat::Nv12,
            PixelFormat::P010,
            PixelFormat::Rgb10a2,
            PixelFormat::Yuv444,
            PixelFormat::X2Rgb10,
            PixelFormat::X2Bgr10,
        ] {
            assert!(sws_src(f).is_err(), "{f:?} must be refused");
        }
    }

    /// The readback routing table, and its depth guard: NV12/P010 take the plane copy, the
    /// video-processor fallbacks take their swscale lanes, a depth CHANGE under the encoder is
    /// refused (in both directions), and depth-consistent routing never trips the guard.
    #[test]
    fn readback_routing_and_depth_guard() {
        assert_eq!(
            readback_route(PixelFormat::Nv12, false).unwrap(),
            ReadbackRoute::Yuv
        );
        assert_eq!(
            readback_route(PixelFormat::P010, true).unwrap(),
            ReadbackRoute::Yuv
        );
        assert_eq!(
            readback_route(PixelFormat::Bgra, false).unwrap(),
            ReadbackRoute::Bgra
        );
        assert_eq!(
            readback_route(PixelFormat::Bgrx, false).unwrap(),
            ReadbackRoute::Bgra
        );
        assert_eq!(
            readback_route(PixelFormat::Rgb10a2, true).unwrap(),
            ReadbackRoute::Rgb10
        );
        // Mid-stream depth changes — the genuine error the guard exists for.
        assert!(readback_route(PixelFormat::P010, false).is_err());
        assert!(readback_route(PixelFormat::Rgb10a2, false).is_err());
        assert!(readback_route(PixelFormat::Nv12, true).is_err());
        assert!(readback_route(PixelFormat::Bgra, true).is_err());
        // A format neither lane can read back.
        assert!(readback_route(PixelFormat::Yuv444, false).is_err());
    }

    /// The 10-bit predicate follows the PIXELS (P010/Rgb10a2), not the negotiated depth — see
    /// `ten_bit_input` for the forever-failing-session shape the reverse produced here.
    #[test]
    fn ten_bit_follows_the_pixels() {
        assert!(is_10bit_format(PixelFormat::P010));
        assert!(is_10bit_format(PixelFormat::Rgb10a2));
        assert!(!is_10bit_format(PixelFormat::Nv12));
        assert!(!is_10bit_format(PixelFormat::Bgra));
        assert!(!is_10bit_format(PixelFormat::Bgrx));
    }

    /// The QSV low-latency contract, pinned: these five knobs are the difference between
    /// display-remoting latency and transcode behavior — a silent regression here changes every
    /// Intel Windows session.
    #[test]
    fn qsv_opts_pin_the_latency_contract() {
        let opts = vendor_opts(WinVendor::Qsv, "ignored");
        let get = |k: &str| {
            opts.iter()
                .find(|(key, _)| *key == k)
                .map(|(_, v)| v.as_str())
        };
        assert_eq!(get("async_depth"), Some("1"));
        assert_eq!(get("low_power"), Some("1"));
        assert_eq!(get("look_ahead"), Some("0"));
        assert_eq!(get("forced_idr"), Some("1"));
        assert_eq!(get("scenario"), Some("displayremoting"));
        assert_eq!(get("preset"), Some("veryfast"));
        assert_eq!(get("usage"), None, "AMF-only knob must not leak into QSV");
    }

    /// The AMF (benchmark-comparator) contract: usage passes through, B-frames are pinned OFF
    /// (each one is a full frame period of latency on RDNA3+), and the low-latency submission
    /// mode + IDR header insertion are requested.
    #[test]
    fn amf_opts_pin_no_bframes_and_the_usage_passthrough() {
        let opts = vendor_opts(WinVendor::Amf, "lowlatency");
        let get = |k: &str| {
            opts.iter()
                .find(|(key, _)| *key == k)
                .map(|(_, v)| v.as_str())
        };
        assert_eq!(get("usage"), Some("lowlatency"));
        assert_eq!(get("bf"), Some("0"));
        assert_eq!(get("rc"), Some("cbr"));
        assert_eq!(get("quality"), Some("speed"));
        assert_eq!(get("latency"), Some("true"));
        assert_eq!(get("header_insertion_mode"), Some("idr"));
        assert_eq!(get("preanalysis"), Some("false"));
        assert_eq!(get("enforce_hrd"), Some("true"));
    }

    /// The zero-copy pool's bind flags per vendor — AMF's encoder-input shape vs QSV's mfx
    /// surface shape (a wrong flag set fails `av_hwframe_ctx_init`, or worse, opens and maps
    /// wrong).
    #[test]
    fn pool_bind_flags_per_vendor() {
        assert_eq!(
            pool_bind_flags(WinVendor::Amf),
            (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32
        );
        assert_eq!(
            pool_bind_flags(WinVendor::Qsv),
            (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32
        );
    }

    /// The libavcodec encoder-name dispatch (name-selected — the codec id would pick the
    /// software encoder).
    #[test]
    fn encoder_names_dispatch_by_vendor() {
        assert_eq!(WinVendor::Qsv.encoder_name(Codec::H264), "h264_qsv");
        assert_eq!(WinVendor::Qsv.encoder_name(Codec::H265), "hevc_qsv");
        assert_eq!(WinVendor::Qsv.encoder_name(Codec::Av1), "av1_qsv");
        assert_eq!(WinVendor::Amf.encoder_name(Codec::H265), "hevc_amf");
    }

    /// Probe smoke: resolve the QSV probe on this machine without crashing — `false` on a box
    /// without the Intel runtime is a valid outcome; the value is printed, not asserted. Only
    /// compiled in the `amf-qsv`-without-`qsv` combo (the shipped combo answers via native VPL).
    /// Run on the Windows CI runner:
    ///   cargo test -p pf-encode --no-default-features --features amf-qsv -- --ignored ffmpeg_win
    #[cfg(not(feature = "qsv"))]
    #[test]
    #[ignore = "needs a real FFmpeg runtime probe (run on the Windows CI runner, not a dev box)"]
    fn ffmpeg_win_probe_smoke() {
        for codec in [Codec::H264, Codec::H265, Codec::Av1] {
            eprintln!(
                "probe_can_encode(Qsv, {codec:?}) = {}",
                probe_can_encode(WinVendor::Qsv, codec)
            );
        }
    }
}
