Skip to main content

pixelflux/encoders/
vaapi.rs

1/*
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5 */
6
7//! Hardware-accelerated H.264 encoding on VA-API through FFmpeg's `h264_vaapi`
8//! encoder. Frames reach the GPU by one of three entry points — a Wayland
9//! DRM-PRIME dmabuf (`encode_dmabuf`), a host BGRA frame from the X11 capture
10//! path (`encode_host_argb`), or already-planar pixels (`encode_raw`) — and an
11//! FFmpeg filter graph runs VA-VPP (`scale_vaapi`) to land the pixels on the GPU
12//! in BT.709 limited range before encode, so no colorspace conversion happens on
13//! the CPU.
14//!
15//! Chroma follows `video_fullcolor`: 4:2:0 lands as NV12, and 4:4:4 lands as
16//! whichever 4:4:4 surface format the VA driver reports. 4:4:4 is negotiated
17//! rather than assumed — see [`fullcolor_sw_format`] — and a device or FFmpeg
18//! build that cannot carry it fails construction so the caller falls back.
19
20// Every operation in these functions is an FFmpeg or VA-API call, or a
21// dereference of a pointer one handed back. Marking each individually would
22// put an `unsafe` block inside nearly every expression while isolating
23// nothing, so the safety contract is carried by the function signatures.
24#![allow(unsafe_op_in_unsafe_fn)]
25
26use std::ffi::{c_char, c_int, c_void, CStr, CString};
27use std::mem;
28use std::os::fd::AsRawFd;
29use std::ptr;
30use std::sync::Once;
31
32use ffmpeg_sys_next as ff;
33use libc::{close, dup, lseek, SEEK_END};
34
35use crate::encoders::QP_HYSTERESIS_LIMIT;
36use crate::RustCaptureSettings;
37use smithay::backend::allocator::{dmabuf::Dmabuf, Buffer};
38
39/// One-time FFmpeg global-init barrier; the closure is empty because modern FFmpeg needs
40/// no explicit codec/filter registration, leaving only the run-once guarantee worth keeping.
41static FF_INIT: Once = Once::new();
42/// Plane/object fan-out of the `AVDRM*` descriptors, matching FFmpeg's `AV_DRM_MAX_PLANES`.
43const AV_DRM_MAX_PLANES: usize = 4;
44/// The 8-bit 4:4:4 surface formats FFmpeg's VA-API hardware context can carry, in the order this
45/// encoder wants them. Planar comes first: the readback path already holds planar I444, so that
46/// surface takes the buffer as it stands, while the packed variant costs a repack per frame.
47const FULLCOLOR_SW_FORMATS: [ff::AVPixelFormat; 2] = [
48    ff::AVPixelFormat::AV_PIX_FMT_YUV444P,
49    ff::AVPixelFormat::AV_PIX_FMT_VUYX,
50];
51
52/// Exists only to mirror FFmpeg's `libavutil/hwcontext_drm.h` ABI so a Wayland dmabuf can be
53/// handed to the `hwmap` filter without a copy.
54///
55/// FFmpeg (C) reinterprets these bytes directly, so this and its sibling `AVDRM*` descriptors carry
56/// no abstraction of their own: every field, order, and `#[repr(C)]` layout must stay
57/// **bit-identical** to the C definitions or the driver reads garbage. This particular struct
58/// describes one backing DRM object (a dmabuf) — its fd, byte size, and DRM format modifier.
59#[repr(C)]
60#[derive(Clone, Copy, Debug)]
61struct AVDRMObjectDescriptor {
62    pub fd: c_int,
63    pub size: usize,
64    pub format_modifier: u64,
65}
66
67/// One plane within a layer: which object holds it, and the byte offset + pitch of the
68/// plane inside that object.
69#[repr(C)]
70#[derive(Clone, Copy, Debug)]
71struct AVDRMPlaneDescriptor {
72    pub object_index: c_int,
73    pub offset: isize,
74    pub pitch: isize,
75}
76
77/// One layer (a single image format) built from up to `AV_DRM_MAX_PLANES` planes.
78#[repr(C)]
79#[derive(Clone, Copy, Debug)]
80struct AVDRMLayerDescriptor {
81    pub format: u32,
82    pub nb_planes: c_int,
83    pub planes: [AVDRMPlaneDescriptor; AV_DRM_MAX_PLANES],
84}
85
86/// Top-level DRM frame descriptor: the set of backing objects plus the layers that index
87/// into them, as consumed by FFmpeg's DRM-PRIME `hwmap`.
88#[repr(C)]
89#[derive(Clone, Copy, Debug)]
90struct AVDRMFrameDescriptor {
91    pub nb_objects: c_int,
92    pub objects: [AVDRMObjectDescriptor; AV_DRM_MAX_PLANES],
93    pub nb_layers: c_int,
94    pub layers: [AVDRMLayerDescriptor; AV_DRM_MAX_PLANES],
95}
96
97/// Decouples fd ownership between FFmpeg and the compositor for one in-flight frame: FFmpeg
98/// closes the fds it is handed, so it must be handed `dup`'d copies rather than the originals the
99/// smithay `Dmabuf` still owns. This box holds those dup'd fds and gives them a defined lifetime —
100/// `release_drm_frame` closes them when FFmpeg tears the wrapping buffer down.
101struct DmabufResources {
102    fds: Vec<c_int>,
103}
104
105/// FFmpeg buffer-free callback for the custom DRM-PRIME frames — closes the dmabuf fds and
106/// frees the descriptor.
107///
108/// FFmpeg (C) invokes this when it tears an `av_buffer_create` buffer down, so the whole body runs
109/// inside `catch_unwind`: a panic must not unwind across the `extern "C"` boundary (that would
110/// abort the process). The `opaque` pointer is reclaimed as the boxed `DmabufResources` and each
111/// dup'd fd is closed. `data` is the descriptor FFmpeg now owns, freed here when non-null; it is
112/// null only on the construction error path, where the caller frees the descriptor itself.
113unsafe extern "C" fn release_drm_frame(opaque: *mut c_void, data: *mut u8) {
114    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
115        let resources = Box::from_raw(opaque as *mut DmabufResources);
116        for &fd in &resources.fds {
117            close(fd);
118        }
119        if !data.is_null() {
120            ff::av_free(data as *mut c_void);
121        }
122    }));
123}
124
125/// An FFmpeg pixel format's canonical name, used both as a `scale_vaapi` argument and in the
126/// messages that explain a failed negotiation.
127fn pix_fmt_name(fmt: ff::AVPixelFormat) -> String {
128    unsafe {
129        let name = ff::av_get_pix_fmt_name(fmt);
130        if name.is_null() {
131            format!("{fmt:?}")
132        } else {
133            CStr::from_ptr(name).to_string_lossy().into_owned()
134        }
135    }
136}
137
138/// The 4:4:4 surface format to encode into on this VA device, or `None` when the driver carries
139/// none of them.
140///
141/// Chroma support is a property of the driver and of the FFmpeg build, not something a GPU vendor
142/// can be assumed to have, so it is asked for rather than inferred. This answers only the driver
143/// half: the format list comes from what the device reports it can carry, which is a pre-screen and
144/// not a guarantee, so the caller still has to survive `av_hwframe_ctx_init` (the driver really
145/// allocating such surfaces) and `avcodec_open2` (`h264_vaapi` really having a matching profile) on
146/// whichever format comes back. Both gates report their own failure, so a rejection says which
147/// layer refused.
148unsafe fn fullcolor_sw_format(device: *mut ff::AVBufferRef) -> Option<ff::AVPixelFormat> {
149    let constraints = ff::av_hwdevice_get_hwframe_constraints(device, ptr::null());
150    if constraints.is_null() {
151        return None;
152    }
153    let mut carried = Vec::new();
154    let mut fmt = (*constraints).valid_sw_formats;
155    if !fmt.is_null() {
156        while *fmt != ff::AVPixelFormat::AV_PIX_FMT_NONE {
157            carried.push(*fmt);
158            fmt = fmt.add(1);
159        }
160    }
161    let mut owned = constraints;
162    ff::av_hwframe_constraints_free(&mut owned);
163    preferred_fullcolor_format(&carried)
164}
165
166/// This encoder's pick out of the formats a device reports it carries, honouring the preference
167/// order in `FULLCOLOR_SW_FORMATS`.
168fn preferred_fullcolor_format(carried: &[ff::AVPixelFormat]) -> Option<ff::AVPixelFormat> {
169    FULLCOLOR_SW_FORMATS
170        .into_iter()
171        .find(|wanted| carried.contains(wanted))
172}
173
174/// Repack planar I444 as packed VUYX, for a driver whose only 4:4:4 surface is the packed one.
175///
176/// `planar` holds `plane` bytes of Y, then U, then V; the destination takes four bytes per pixel in
177/// V, U, Y, X order. The fourth byte is undefined in this format and is written opaque, which is
178/// what FFmpeg's own conversion emits and what a driver reading the surface as XYUV expects.
179/// `packed` is the caller's reused scratch buffer so a steady stream allocates nothing.
180fn pack_i444_as_vuyx(planar: &[u8], packed: &mut Vec<u8>, plane: usize) {
181    packed.resize(plane * 4, 0);
182    let (y, chroma) = planar.split_at(plane);
183    let (u, v) = chroma.split_at(plane);
184    for (i, px) in packed.chunks_exact_mut(4).enumerate() {
185        px[0] = v[i];
186        px[1] = u[i];
187        px[2] = y[i];
188        px[3] = 0xFF;
189    }
190}
191
192/// Format an FFmpeg error code as its human-readable string via `av_strerror`.
193fn ff_err_str(err: i32) -> String {
194    unsafe {
195        let mut errbuf = [0 as c_char; 128];
196        ff::av_strerror(err, errbuf.as_mut_ptr(), 128);
197        CStr::from_ptr(errbuf.as_ptr())
198            .to_string_lossy()
199            .into_owned()
200    }
201}
202
203/// Declare the stream's colour description on a codec context, which is the only place
204/// `h264_vaapi` takes it from: with these fields unset it writes no VUI colour info at all, and a
205/// client then has to guess — a WebRTC receiver infers range from the negotiated SDP profile, so a
206/// 4:4:4 session would display this encoder's limited-range picture unexpanded and visibly dark.
207///
208/// The values state what the filter graph actually produces: `scale_vaapi` converts to BT.709
209/// limited range (`out_color_matrix=bt709:out_range=tv`) in both chroma formats, and the desktop
210/// source is sRGB, whose primaries and transfer function are BT.709's. Every open of a context
211/// (initial and every `reopen_codec`) has to set them, since a fresh context starts unspecified.
212unsafe fn set_colorimetry(ctx: *mut ff::AVCodecContext) {
213    (*ctx).color_range = ff::AVColorRange::AVCOL_RANGE_MPEG;
214    (*ctx).colorspace = ff::AVColorSpace::AVCOL_SPC_BT709;
215    (*ctx).color_primaries = ff::AVColorPrimaries::AVCOL_PRI_BT709;
216    (*ctx).color_trc = ff::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
217}
218
219/// Hardware-accelerated H.264 encoder built on FFmpeg's `h264_vaapi`, owning the whole
220/// VA-API pipeline for one capture: device contexts, the surface pool, the color-convert
221/// filter graph, the reusable frames/packet, and live rate-control state.
222///
223/// The pointer members are raw FFmpeg objects freed in `Drop`. Three groups matter:
224///
225/// 1. **Device / frames contexts**: `drm_device_ctx` → derived `hw_device_ctx`; `drm_frames_ctx`
226///    describes the incoming DMA-BUF, `enc_frames_ctx` the VA-surface pool the encoder draws from.
227///    `enc_frames_ctx` is kept referenced so `reopen_codec` can rebuild the codec against the same
228///    pool.
229/// 2. **Filter graph**: `buffersrc_ctx` → hwmap/hwupload + `scale_vaapi` → `buffersink_ctx`, which
230///    lands every input on a GPU surface in `sw_format`.
231/// 3. **Reusable frames**: `video_frame` feeds the graph on the dmabuf/host paths, `sw_frame` +
232///    `hw_frame` stage the direct planar upload in `encode_raw`, and `packet` is the shared output.
233///
234/// `sw_format` is the surface format the session negotiated — NV12 for 4:2:0, or the 4:4:4 format
235/// the driver offered — and every path keys its plane layout off it; `packed_444` is the reused
236/// repack scratch for a driver whose only 4:4:4 surface is packed.
237///
238/// `current_qp` / `qp_hysteresis_counter` drive the CQP hysteresis in `update_qp`. `cbr_mode`,
239/// `current_bitrate_kbps`, `current_vbv_mult`, and `current_kf_s` cache the live rate-control state
240/// so `reconfigure_rate` re-opens the codec only when a value actually changes.
241/// `omit_stripe_headers` drops the 10-byte framing when the consumer wants a bare Annex-B stream.
242pub struct VaapiEncoder {
243    encoder_ctx: *mut ff::AVCodecContext,
244    codec: *const ff::AVCodec,
245
246    #[allow(dead_code)]
247    hw_device_ctx: *mut ff::AVBufferRef,
248    #[allow(dead_code)]
249    drm_device_ctx: *mut ff::AVBufferRef,
250    #[allow(dead_code)]
251    drm_frames_ctx: *mut ff::AVBufferRef,
252    
253    enc_frames_ctx: *mut ff::AVBufferRef,
254
255    filter_graph: *mut ff::AVFilterGraph,
256    buffersrc_ctx: *mut ff::AVFilterContext,
257    buffersink_ctx: *mut ff::AVFilterContext,
258
259    video_frame: *mut ff::AVFrame,
260    sw_frame: *mut ff::AVFrame,
261    hw_frame: *mut ff::AVFrame,
262
263    packet: *mut ff::AVPacket,
264
265    width: i32,
266    height: i32,
267    fps: i32,
268
269    sw_format: ff::AVPixelFormat,
270    packed_444: Vec<u8>,
271
272    current_qp: u32,
273    qp_hysteresis_counter: u32,
274
275    cbr_mode: bool,
276    current_bitrate_kbps: i32,
277    current_vbv_mult: f64,
278    current_kf_s: f64,
279
280    omit_stripe_headers: bool,
281}
282
283/// Assert `VaapiEncoder` is `Send`: its raw FFmpeg pointers are owned exclusively and the
284/// encoder is driven from a single capture thread, so moving the whole object across threads adds
285/// no aliasing.
286unsafe impl Send for VaapiEncoder {}
287
288/// Tear down every FFmpeg object in dependency order so nothing is freed while still
289/// referenced: first the reusable packet and frames, then the filter graph and codec context, and
290/// last the frames/device contexts they pointed at (encoder pool, DRM frames, VA-API device, DRM
291/// device). Each pointer is null-checked so a partially-built encoder unwinds cleanly.
292impl Drop for VaapiEncoder {
293    fn drop(&mut self) {
294        unsafe {
295            if !self.packet.is_null() {
296                ff::av_packet_free(&mut self.packet);
297            }
298            if !self.video_frame.is_null() {
299                ff::av_frame_free(&mut self.video_frame);
300            }
301            if !self.sw_frame.is_null() {
302                ff::av_frame_free(&mut self.sw_frame);
303            }
304            if !self.hw_frame.is_null() {
305                ff::av_frame_free(&mut self.hw_frame);
306            }
307
308            if !self.filter_graph.is_null() {
309                ff::avfilter_graph_free(&mut self.filter_graph);
310            }
311            if !self.encoder_ctx.is_null() {
312                ff::avcodec_free_context(&mut self.encoder_ctx);
313            }
314
315            if !self.enc_frames_ctx.is_null() {
316                ff::av_buffer_unref(&mut self.enc_frames_ctx);
317            }
318            if !self.drm_frames_ctx.is_null() {
319                ff::av_buffer_unref(&mut self.drm_frames_ctx);
320            }
321            if !self.hw_device_ctx.is_null() {
322                ff::av_buffer_unref(&mut self.hw_device_ctx);
323            }
324            if !self.drm_device_ctx.is_null() {
325                ff::av_buffer_unref(&mut self.drm_device_ctx);
326            }
327        }
328    }
329}
330
331impl VaapiEncoder {
332    /// Build a VA-API encoder for the Wayland **dmabuf** path — the source is a DRM-PRIME
333    /// dmabuf that the filter graph `hwmap`s onto a VA surface. Thin wrapper over `new_impl` with
334    /// `host_input = false`.
335    pub fn new(
336        settings: &RustCaptureSettings,
337    ) -> Result<Self, String> {
338        Self::new_impl(settings, false)
339    }
340
341    /// Build a VA-API encoder for the X11 **host-ARGB** path — the source is a CPU BGRA frame
342    /// that the filter graph `hwupload`s onto a VA surface. Thin wrapper over `new_impl` with
343    /// `host_input = true`; the GPU still does the ARGB→YUV convert, so there is no CPU colorspace
344    /// conversion.
345    pub fn new_host(
346        settings: &RustCaptureSettings,
347    ) -> Result<Self, String> {
348        Self::new_impl(settings, true)
349    }
350
351    /// Stand up the whole VA-API pipeline for one capture, shared by the dmabuf and
352    /// host-ARGB entry points and selected by `host_input`.
353    ///
354    /// **Why it is built this way.** Every piece here exists to push all pixel-format work onto the
355    /// GPU: the capture thread should hand over a dmabuf or a raw host frame and get H.264 back
356    /// without the CPU ever performing a colorspace conversion. One function serves both capture
357    /// backends so their device, pool, codec, and filter-graph setup cannot drift apart; `host_input`
358    /// selects only the two points where the paths genuinely differ — the buffersrc pixel format and
359    /// the `hwupload` vs `hwmap` staging filter.
360    ///
361    /// 1. **Devices**: open a DRM device on the chosen render node (`/dev/dri/renderD{128+index}`,
362    ///    or `renderD128` when no index is set) and derive a VA-API device from it.
363    /// 2. **Frames contexts**: a DRM-PRIME frames context (sw-format BGRA) describes the incoming
364    ///    Wayland dmabufs; the encoder frames context is a pool of `initial_pool_size = 20` VA-API
365    ///    surfaces at macroblock-aligned dimensions (width→16, height→32), in NV12 or — when
366    ///    `video_fullcolor` asks for 4:4:4 and `fullcolor_sw_format` finds one the driver carries —
367    ///    that 4:4:4 format. A second ref to the encoder pool is saved so `reopen_codec` can rebuild
368    ///    the codec against it.
369    /// 3. **Codec context**: `h264_vaapi` with an effectively **infinite GOP** (`gop_size = INT_MAX`,
370    ///    IDRs only on demand), **no B-frames** (low latency), **4 slices** (lets client decoders
371    ///    parallelize; more than 4 upsets Chromium), and `compression_level = 6` (the VA quality
372    ///    knob, biased toward speed — higher is faster). Rate control is either **CBR** (program
373    ///    `bit_rate`/`rc_max_rate` and a `vbv_bits`-derived `rc_buffer_size`, `rc_mode=CBR`) or
374    ///    **CQP** (`rc_mode=CQP` plus a fixed `qp`); both then pin the minimum `level` and
375    ///    `async_depth=1`, and a 4:2:0 session also pins `profile=high`. `set_colorimetry` declares
376    ///    the BT.709 limited-range VUI the graph produces.
377    /// 4. **Filter graph**: an explicit `buffersrc` → `hwmap`/`hwupload` + `scale_vaapi` →
378    ///    `buffersink` chain. The buffersrc format is DRM-PRIME (carrying the DRM frames context) for
379    ///    dmabuf input, or plain BGRA for host input. `scale_vaapi` does the ARGB→YUV convert on the
380    ///    GPU in **BT.709 limited range** (`out_range=tv`) so VA-API output matches the NVENC/x264
381    ///    color — an explicit convert is used rather than trusting encoder-side RGB CSC, which
382    ///    varies across VA drivers.
383    /// 5. **Graph staging**: the chain is built with the segment API (parse → create filters →
384    ///    attach the VA device to every filter → apply → link our endpoints to the dangling pads)
385    ///    rather than the one-shot parser. `hwupload` initializes *during* the parse and fails
386    ///    without a device, and on the host path the BGRA buffersrc carries no frames context to
387    ///    derive one from; staging mirrors what the ffmpeg CLI does so the device is attached before
388    ///    those filters init.
389    /// 6. **Preflight**: allocate the reusable `video_frame`/`sw_frame`/`hw_frame` and pull one
390    ///    buffer from the encoder pool to prove the surface path is live before returning.
391    ///
392    /// Every failure step unwinds by unref-ing exactly the contexts allocated so far, in reverse
393    /// order, so no FFmpeg object leaks on the error path.
394    fn new_impl(
395        settings: &RustCaptureSettings,
396        host_input: bool,
397    ) -> Result<Self, String> {
398        FF_INIT.call_once(|| {});
399
400        let width = settings.width;
401        let height = settings.height;
402        // AVRational.den must stay nonzero: a sub-1 fps setting would truncate to 0.
403        let fps = (settings.target_fps as i32).max(1);
404
405        unsafe {
406            let mut drm_device_ctx: *mut ff::AVBufferRef = ptr::null_mut();
407            let render_node = if settings.encode_node_index >= 0 {
408                format!("/dev/dri/renderD{}", 128 + settings.encode_node_index)
409            } else {
410                "/dev/dri/renderD128".to_string()
411            };
412            let device_url = CString::new(render_node).unwrap();
413
414            let ret = ff::av_hwdevice_ctx_create(
415                &mut drm_device_ctx,
416                ff::AVHWDeviceType::AV_HWDEVICE_TYPE_DRM,
417                device_url.as_ptr(),
418                ptr::null_mut(),
419                0,
420            );
421            if ret < 0 {
422                return Err(format!("Failed to create DRM device: {}", ff_err_str(ret)));
423            }
424
425            let mut hw_device_ctx: *mut ff::AVBufferRef = ptr::null_mut();
426            let ret = ff::av_hwdevice_ctx_create_derived(
427                &mut hw_device_ctx,
428                ff::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
429                drm_device_ctx,
430                0,
431            );
432            if ret < 0 {
433                ff::av_buffer_unref(&mut drm_device_ctx);
434                return Err(format!(
435                    "Failed to derive VAAPI device: {}",
436                    ff_err_str(ret)
437                ));
438            }
439
440            let enc_sw_format = if settings.video_fullcolor {
441                match fullcolor_sw_format(hw_device_ctx) {
442                    Some(fmt) => fmt,
443                    None => {
444                        ff::av_buffer_unref(&mut hw_device_ctx);
445                        ff::av_buffer_unref(&mut drm_device_ctx);
446                        return Err(
447                            "4:4:4 requested but this VA-API driver carries no 4:4:4 surface format"
448                                .into(),
449                        );
450                    }
451                }
452            } else {
453                ff::AVPixelFormat::AV_PIX_FMT_NV12
454            };
455
456            let mut drm_frames_ref = ff::av_hwframe_ctx_alloc(drm_device_ctx);
457            if drm_frames_ref.is_null() {
458                ff::av_buffer_unref(&mut hw_device_ctx);
459                ff::av_buffer_unref(&mut drm_device_ctx);
460                return Err("Failed to alloc DRM frames ctx".into());
461            }
462
463            let drm_frames = (*drm_frames_ref).data as *mut ff::AVHWFramesContext;
464            (*drm_frames).format = ff::AVPixelFormat::AV_PIX_FMT_DRM_PRIME;
465            (*drm_frames).sw_format = ff::AVPixelFormat::AV_PIX_FMT_BGRA;
466            (*drm_frames).width = width;
467            (*drm_frames).height = height;
468            (*drm_frames).initial_pool_size = 0;
469
470            if ff::av_hwframe_ctx_init(drm_frames_ref) < 0 {
471                ff::av_buffer_unref(&mut drm_frames_ref);
472                ff::av_buffer_unref(&mut hw_device_ctx);
473                ff::av_buffer_unref(&mut drm_device_ctx);
474                return Err("Failed to init DRM frames ctx".into());
475            }
476
477            let codec_name = CString::new("h264_vaapi").unwrap();
478            let codec = ff::avcodec_find_encoder_by_name(codec_name.as_ptr());
479            if codec.is_null() {
480                ff::av_buffer_unref(&mut drm_frames_ref);
481                ff::av_buffer_unref(&mut hw_device_ctx);
482                ff::av_buffer_unref(&mut drm_device_ctx);
483                return Err("h264_vaapi encoder not found".into());
484            }
485
486            let aligned_width = (width + 15) & !15;
487            let aligned_height = (height + 31) & !31;
488
489            let mut enc_frames_ref = ff::av_hwframe_ctx_alloc(hw_device_ctx);
490            if enc_frames_ref.is_null() {
491                ff::av_buffer_unref(&mut drm_frames_ref);
492                ff::av_buffer_unref(&mut hw_device_ctx);
493                ff::av_buffer_unref(&mut drm_device_ctx);
494                return Err("Failed to allocate encoder frames ctx".into());
495            }
496            let enc_frames = (*enc_frames_ref).data as *mut ff::AVHWFramesContext;
497            (*enc_frames).format = ff::AVPixelFormat::AV_PIX_FMT_VAAPI;
498            (*enc_frames).sw_format = enc_sw_format;
499            (*enc_frames).width = aligned_width;
500            (*enc_frames).height = aligned_height;
501            (*enc_frames).initial_pool_size = 20;
502
503            if ff::av_hwframe_ctx_init(enc_frames_ref) < 0 {
504                ff::av_buffer_unref(&mut enc_frames_ref);
505                ff::av_buffer_unref(&mut drm_frames_ref);
506                ff::av_buffer_unref(&mut hw_device_ctx);
507                ff::av_buffer_unref(&mut drm_device_ctx);
508                return Err(format!(
509                    "Failed to init encoder frames ctx: this VA-API driver allocates no {} surfaces",
510                    pix_fmt_name(enc_sw_format)
511                ));
512            }
513
514            let mut saved_enc_frames_ctx = ff::av_buffer_ref(enc_frames_ref);
515
516            let mut encoder_ctx = ff::avcodec_alloc_context3(codec);
517            if encoder_ctx.is_null() {
518                ff::av_buffer_unref(&mut saved_enc_frames_ctx);
519                ff::av_buffer_unref(&mut enc_frames_ref);
520                ff::av_buffer_unref(&mut drm_frames_ref);
521                ff::av_buffer_unref(&mut hw_device_ctx);
522                ff::av_buffer_unref(&mut drm_device_ctx);
523                return Err("Failed to allocate encoder context".into());
524            }
525            (*encoder_ctx).width = width;
526            (*encoder_ctx).height = height;
527            (*encoder_ctx).time_base = ff::AVRational { num: 1, den: fps };
528            (*encoder_ctx).framerate = ff::AVRational { num: fps, den: 1 };
529            (*encoder_ctx).pix_fmt = ff::AVPixelFormat::AV_PIX_FMT_VAAPI;
530            (*encoder_ctx).hw_device_ctx = ff::av_buffer_ref(hw_device_ctx);
531            (*encoder_ctx).hw_frames_ctx = ff::av_buffer_ref(enc_frames_ref);
532            (*encoder_ctx).max_b_frames = 0;
533            (*encoder_ctx).gop_size = std::ffi::c_int::MAX;
534            (*encoder_ctx).slices = 4;
535            (*encoder_ctx).compression_level = 6;
536            set_colorimetry(encoder_ctx);
537
538            ff::av_buffer_unref(&mut enc_frames_ref);
539
540            let mut opts: *mut ff::AVDictionary = ptr::null_mut();
541            let set_opt = |d: &mut *mut ff::AVDictionary, k: &str, v: &str| {
542                let ck = CString::new(k).unwrap();
543                let cv = CString::new(v).unwrap();
544                ff::av_dict_set(d, ck.as_ptr(), cv.as_ptr(), 0);
545            };
546
547            if settings.video_cbr_mode {
548                let bps = (settings.video_bitrate_kbps.max(0) as i64).saturating_mul(1000);
549                let vbv = crate::encoders::vbv_bits(
550                    bps.min(u32::MAX as i64) as u32,
551                    settings.target_fps,
552                    settings.keyframe_interval_s,
553                    settings.video_vbv_multiplier,
554                );
555                (*encoder_ctx).bit_rate = bps;
556                (*encoder_ctx).rc_max_rate = bps;
557                (*encoder_ctx).rc_buffer_size = vbv.min(i32::MAX as u32) as i32;
558                set_opt(&mut opts, "rc_mode", "CBR");
559            } else {
560                set_opt(&mut opts, "rc_mode", "CQP");
561                set_opt(&mut opts, "qp", &settings.video_crf.to_string());
562            }
563            set_opt(&mut opts, "async_depth", "1");
564            // Naming a profile is what pins 4:2:0 to High. A 4:4:4 session leaves it unset so
565            // FFmpeg matches a profile against the surface format instead, which is the one place
566            // a build whose h264_vaapi gains a 4:4:4 entry starts using it without a code change.
567            if enc_sw_format == ff::AVPixelFormat::AV_PIX_FMT_NV12 {
568                set_opt(&mut opts, "profile", "high");
569            }
570            set_opt(
571                &mut opts,
572                "level",
573                &super::min_h264_level(width as u32, height as u32, fps as u32).to_string(),
574            );
575
576            let ret = ff::avcodec_open2(encoder_ctx, codec, &mut opts);
577            ff::av_dict_free(&mut opts);
578            if ret < 0 {
579                ff::avcodec_free_context(&mut encoder_ctx);
580                ff::av_buffer_unref(&mut saved_enc_frames_ctx);
581                ff::av_buffer_unref(&mut drm_frames_ref);
582                ff::av_buffer_unref(&mut hw_device_ctx);
583                ff::av_buffer_unref(&mut drm_device_ctx);
584                if enc_sw_format != ff::AVPixelFormat::AV_PIX_FMT_NV12 {
585                    return Err(format!(
586                        "Failed to open encoder for 4:4:4 ({}): {}. This FFmpeg build's h264_vaapi \
587                         advertises no 4:4:4 profile.",
588                        pix_fmt_name(enc_sw_format),
589                        ff_err_str(ret)
590                    ));
591                }
592                return Err(format!("Failed to open encoder: {}", ff_err_str(ret)));
593            }
594
595            let mut filter_graph = ff::avfilter_graph_alloc();
596            let buffersrc = ff::avfilter_get_by_name(CString::new("buffer").unwrap().as_ptr());
597            let buffersink =
598                ff::avfilter_get_by_name(CString::new("buffersink").unwrap().as_ptr());
599            let name_in = CString::new("in").unwrap();
600            let name_out = CString::new("out").unwrap();
601
602            let buffersrc_ctx =
603                ff::avfilter_graph_alloc_filter(filter_graph, buffersrc, name_in.as_ptr());
604
605            let par = ff::av_buffersrc_parameters_alloc();
606            if par.is_null() {
607                ff::avfilter_graph_free(&mut filter_graph);
608                ff::avcodec_free_context(&mut encoder_ctx);
609                ff::av_buffer_unref(&mut saved_enc_frames_ctx);
610                ff::av_buffer_unref(&mut drm_frames_ref);
611                ff::av_buffer_unref(&mut hw_device_ctx);
612                ff::av_buffer_unref(&mut drm_device_ctx);
613                return Err("Failed to alloc buffersrc parameters".into());
614            }
615            if host_input {
616                (*par).format = ff::AVPixelFormat::AV_PIX_FMT_BGRA as i32;
617            } else {
618                (*par).format = ff::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
619                (*par).hw_frames_ctx = ff::av_buffer_ref(drm_frames_ref);
620            }
621            (*par).width = width;
622            (*par).height = height;
623            (*par).time_base = ff::AVRational { num: 1, den: fps };
624
625            let ret = ff::av_buffersrc_parameters_set(buffersrc_ctx, par);
626            if !(*par).hw_frames_ctx.is_null() {
627                ff::av_buffer_unref(&mut (*par).hw_frames_ctx);
628            }
629            ff::av_free(par as *mut c_void);
630            if ret < 0 {
631                ff::avfilter_graph_free(&mut filter_graph);
632                ff::avcodec_free_context(&mut encoder_ctx);
633                ff::av_buffer_unref(&mut saved_enc_frames_ctx);
634                ff::av_buffer_unref(&mut drm_frames_ref);
635                ff::av_buffer_unref(&mut hw_device_ctx);
636                ff::av_buffer_unref(&mut drm_device_ctx);
637                return Err(format!(
638                    "Failed to set buffersrc parameters: {}",
639                    ff_err_str(ret)
640                ));
641            }
642
643            let args_str = format!(
644                "video_size={}x{}:time_base=1/{}:pixel_aspect=1/1",
645                width, height, fps
646            );
647            let args = CString::new(args_str).unwrap();
648            if ff::avfilter_init_str(buffersrc_ctx, args.as_ptr()) < 0 {
649                ff::avfilter_graph_free(&mut filter_graph);
650                ff::avcodec_free_context(&mut encoder_ctx);
651                ff::av_buffer_unref(&mut saved_enc_frames_ctx);
652                ff::av_buffer_unref(&mut drm_frames_ref);
653                ff::av_buffer_unref(&mut hw_device_ctx);
654                ff::av_buffer_unref(&mut drm_device_ctx);
655                return Err("Failed to init buffersrc".into());
656            }
657
658            let mut buffersink_ctx: *mut ff::AVFilterContext = ptr::null_mut();
659            if ff::avfilter_graph_create_filter(
660                &mut buffersink_ctx,
661                buffersink,
662                name_out.as_ptr(),
663                ptr::null(),
664                ptr::null_mut(),
665                filter_graph,
666            ) < 0
667            {
668                ff::avfilter_graph_free(&mut filter_graph);
669                ff::avcodec_free_context(&mut encoder_ctx);
670                ff::av_buffer_unref(&mut saved_enc_frames_ctx);
671                ff::av_buffer_unref(&mut drm_frames_ref);
672                ff::av_buffer_unref(&mut hw_device_ctx);
673                ff::av_buffer_unref(&mut drm_device_ctx);
674                return Err("Failed to create buffersink".into());
675            }
676
677            let stage = if host_input { "hwupload" } else { "hwmap" };
678            let filters_desc = CString::new(format!(
679                "{},scale_vaapi=w={}:h={}:format={}:out_color_matrix=bt709:out_range=tv",
680                stage,
681                width,
682                height,
683                pix_fmt_name(enc_sw_format)
684            ))
685            .unwrap();
686            let mut seg: *mut ff::AVFilterGraphSegment = ptr::null_mut();
687            let mut seg_inputs: *mut ff::AVFilterInOut = ptr::null_mut();
688            let mut seg_outputs: *mut ff::AVFilterInOut = ptr::null_mut();
689            let seg_ok = ff::avfilter_graph_segment_parse(
690                filter_graph,
691                filters_desc.as_ptr(),
692                0,
693                &mut seg,
694            ) >= 0
695                && ff::avfilter_graph_segment_create_filters(seg, 0) >= 0
696                && {
697                    for i in 0..(*filter_graph).nb_filters {
698                        let f = *(*filter_graph).filters.add(i as usize);
699                        if (*f).hw_device_ctx.is_null() {
700                            (*f).hw_device_ctx = ff::av_buffer_ref(hw_device_ctx);
701                        }
702                    }
703                    ff::avfilter_graph_segment_apply(seg, 0, &mut seg_inputs, &mut seg_outputs)
704                        >= 0
705                }
706                && !seg_inputs.is_null()
707                && !seg_outputs.is_null()
708                && ff::avfilter_link(
709                    buffersrc_ctx,
710                    0,
711                    (*seg_inputs).filter_ctx,
712                    (*seg_inputs).pad_idx as u32,
713                ) >= 0
714                && ff::avfilter_link(
715                    (*seg_outputs).filter_ctx,
716                    (*seg_outputs).pad_idx as u32,
717                    buffersink_ctx,
718                    0,
719                ) >= 0;
720            ff::avfilter_inout_free(&mut seg_inputs);
721            ff::avfilter_inout_free(&mut seg_outputs);
722            ff::avfilter_graph_segment_free(&mut seg);
723            if !seg_ok {
724                ff::avfilter_graph_free(&mut filter_graph);
725                ff::avcodec_free_context(&mut encoder_ctx);
726                ff::av_buffer_unref(&mut saved_enc_frames_ctx);
727                ff::av_buffer_unref(&mut drm_frames_ref);
728                ff::av_buffer_unref(&mut hw_device_ctx);
729                ff::av_buffer_unref(&mut drm_device_ctx);
730                return Err("Failed to build filter graph".into());
731            }
732
733            if ff::avfilter_graph_config(filter_graph, ptr::null_mut()) < 0 {
734                ff::avfilter_graph_free(&mut filter_graph);
735                ff::avcodec_free_context(&mut encoder_ctx);
736                ff::av_buffer_unref(&mut saved_enc_frames_ctx);
737                ff::av_buffer_unref(&mut drm_frames_ref);
738                ff::av_buffer_unref(&mut hw_device_ctx);
739                ff::av_buffer_unref(&mut drm_device_ctx);
740                return Err("Failed to config filter graph".into());
741            }
742
743            let mut video_frame = ff::av_frame_alloc();
744            let mut sw_frame = ff::av_frame_alloc();
745            let mut hw_frame = ff::av_frame_alloc();
746
747            if ff::av_hwframe_get_buffer((*encoder_ctx).hw_frames_ctx, hw_frame, 0) < 0 {
748                ff::av_frame_free(&mut hw_frame);
749                ff::av_frame_free(&mut sw_frame);
750                ff::av_frame_free(&mut video_frame);
751                ff::avfilter_graph_free(&mut filter_graph);
752                ff::avcodec_free_context(&mut encoder_ctx);
753                ff::av_buffer_unref(&mut saved_enc_frames_ctx);
754                ff::av_buffer_unref(&mut drm_frames_ref);
755                ff::av_buffer_unref(&mut hw_device_ctx);
756                ff::av_buffer_unref(&mut drm_device_ctx);
757                return Err("Failed to allocate HW frame from the encoder pool".into());
758            }
759
760            Ok(Self {
761                encoder_ctx,
762                codec,
763                hw_device_ctx,
764                drm_device_ctx,
765                drm_frames_ctx: drm_frames_ref,
766                enc_frames_ctx: saved_enc_frames_ctx,
767                filter_graph,
768                buffersrc_ctx,
769                buffersink_ctx,
770                video_frame,
771                sw_frame,
772                hw_frame,
773                packet: ff::av_packet_alloc(),
774                width,
775                height,
776                fps,
777                sw_format: enc_sw_format,
778                packed_444: Vec::new(),
779                current_qp: settings.video_crf as u32,
780                qp_hysteresis_counter: 0,
781                cbr_mode: settings.video_cbr_mode,
782                current_bitrate_kbps: settings.video_bitrate_kbps,
783                current_vbv_mult: settings.video_vbv_multiplier,
784                current_kf_s: settings.keyframe_interval_s,
785                omit_stripe_headers: settings.omit_stripe_headers,
786            })
787        }
788    }
789
790    /// Whether this session negotiated 4:4:4 chroma. The request alone does not settle it — the
791    /// driver and the FFmpeg build both have to carry it — so callers describing the active
792    /// colorspace ask the encoder rather than the settings.
793    pub fn is_fullcolor(&self) -> bool {
794        self.sw_format != ff::AVPixelFormat::AV_PIX_FMT_NV12
795    }
796
797    /// Applies every live QP / bitrate / fps change by re-opening the whole codec context,
798    /// because VA-API drivers do not reliably honor an in-place reconfigure — a fresh
799    /// `AVCodecContext` is the one portable way to make a new rate-control setting actually take
800    /// effect.
801    ///
802    /// The price of that reliability is that a re-opened context always emits an IDR as its first
803    /// frame, which is affordable here precisely because that IDR simply re-anchors the reference
804    /// chain, so the stream self-heals across the swap rather than breaking. Only the codec context
805    /// is torn down and rebuilt: the VA device, the encoder frames pool, and the filter graph all
806    /// persist, and the new context is re-allocated against the same `codec` and re-bound to the
807    /// saved `enc_frames_ctx` pool and `hw_device_ctx`, with the same GOP / slice / compression /
808    /// colour-description settings as the initial open. **CBR** reprograms `bit_rate` /
809    /// `rc_max_rate` / `rc_buffer_size` (VBV from `vbv_bits`); **CQP** reprograms the quantizer to
810    /// `qp` and records it in `current_qp`.
811    unsafe fn reopen_codec(&mut self, qp: u32) -> Result<(), String> {
812        if !self.encoder_ctx.is_null() {
813            ff::avcodec_free_context(&mut self.encoder_ctx);
814        }
815
816        self.encoder_ctx = ff::avcodec_alloc_context3(self.codec);
817        if self.encoder_ctx.is_null() {
818            return Err("Failed to re-alloc encoder context".into());
819        }
820
821        (*self.encoder_ctx).width = self.width;
822        (*self.encoder_ctx).height = self.height;
823        (*self.encoder_ctx).time_base = ff::AVRational { num: 1, den: self.fps };
824        (*self.encoder_ctx).framerate = ff::AVRational { num: self.fps, den: 1 };
825        (*self.encoder_ctx).pix_fmt = ff::AVPixelFormat::AV_PIX_FMT_VAAPI;
826        (*self.encoder_ctx).hw_device_ctx = ff::av_buffer_ref(self.hw_device_ctx);
827        (*self.encoder_ctx).hw_frames_ctx = ff::av_buffer_ref(self.enc_frames_ctx);
828        (*self.encoder_ctx).max_b_frames = 0;
829        (*self.encoder_ctx).gop_size = std::ffi::c_int::MAX;
830        (*self.encoder_ctx).slices = 4;
831        (*self.encoder_ctx).compression_level = 6;
832        set_colorimetry(self.encoder_ctx);
833
834        let mut opts: *mut ff::AVDictionary = ptr::null_mut();
835        let set_opt = |d: &mut *mut ff::AVDictionary, k: &str, v: &str| {
836            let ck = CString::new(k).unwrap();
837            let cv = CString::new(v).unwrap();
838            ff::av_dict_set(d, ck.as_ptr(), cv.as_ptr(), 0);
839        };
840
841        if self.cbr_mode {
842            let bps = (self.current_bitrate_kbps.max(0) as i64).saturating_mul(1000);
843            let vbv = crate::encoders::vbv_bits(
844                bps.min(u32::MAX as i64) as u32,
845                self.fps.max(1) as f64,
846                self.current_kf_s,
847                self.current_vbv_mult,
848            );
849            (*self.encoder_ctx).bit_rate = bps;
850            (*self.encoder_ctx).rc_max_rate = bps;
851            (*self.encoder_ctx).rc_buffer_size = vbv.min(i32::MAX as u32) as i32;
852            set_opt(&mut opts, "rc_mode", "CBR");
853        } else {
854            set_opt(&mut opts, "rc_mode", "CQP");
855            set_opt(&mut opts, "qp", &qp.to_string());
856        }
857        set_opt(&mut opts, "async_depth", "1");
858        if !self.is_fullcolor() {
859            set_opt(&mut opts, "profile", "high");
860        }
861        set_opt(
862            &mut opts,
863            "level",
864            &super::min_h264_level(self.width as u32, self.height as u32, self.fps.max(1) as u32)
865                .to_string(),
866        );
867
868        let ret = ff::avcodec_open2(self.encoder_ctx, self.codec, &mut opts);
869        ff::av_dict_free(&mut opts);
870
871        if ret < 0 {
872            // A failed open leaves the context unopened with its hw_frames_ctx released, so
873            // it is freed outright: the encode entry points refuse a null context and the
874            // caller rebuilds the session.
875            ff::avcodec_free_context(&mut self.encoder_ctx);
876            return Err(format!("Failed to re-open encoder: {}", ff_err_str(ret)));
877        }
878
879        self.current_qp = qp;
880        Ok(())
881    }
882
883    /// The encode entry points run behind this: a rate or QP re-open that failed leaves no
884    /// codec context, and the session has to be rebuilt rather than encoded into.
885    fn require_open_codec(&self) -> Result<(), String> {
886        if self.encoder_ctx.is_null() {
887            return Err("no open codec context after a failed re-open; the session needs a rebuild".into());
888        }
889        Ok(())
890    }
891
892    /// Moves the CQP quantizer toward `target_qp`, but weighs each change against the cost of
893    /// acting on it, because applying a quantizer is not free — it forces a codec re-open (and thus
894    /// an IDR) — and the two directions are not equally worth that cost.
895    ///
896    /// A *decrease* sharpens the picture and is always worth an immediate switch; an *increase* dulls
897    /// it and is worth committing to only once the drop has clearly persisted, so transient motion
898    /// does not make quality blink. That asymmetry is the whole point of the hysteresis. Resolved in
899    /// priority order:
900    ///
901    /// 1. **CBR**: no-op — the bitrate target, not the quantizer, governs quality, so re-opening per
902    ///    frame would only flip the context into CQP and abandon the configured bitrate.
903    /// 2. **Unchanged QP**: reset the hysteresis counter and return.
904    /// 3. **QP decrease** (higher quality, e.g. a paint-over refresh): apply immediately via
905    ///    `reopen_codec` — a sharper static image is always worth the switch.
906    /// 4. **QP increase** (lower quality, e.g. sustained motion): count consecutive requests and only
907    ///    re-open once the counter exceeds `QP_HYSTERESIS_LIMIT`, so brief motion does not make
908    ///    quality visibly blink between re-opens.
909    unsafe fn update_qp(&mut self, target_qp: u32) -> Result<(), String> {
910        if self.cbr_mode {
911            return Ok(());
912        }
913
914        if target_qp == self.current_qp {
915            self.qp_hysteresis_counter = 0;
916            return Ok(());
917        }
918
919        if target_qp < self.current_qp {
920            self.qp_hysteresis_counter = 0;
921            self.reopen_codec(target_qp)?;
922        } else {
923            self.qp_hysteresis_counter += 1;
924            if self.qp_hysteresis_counter > QP_HYSTERESIS_LIMIT {
925                self.qp_hysteresis_counter = 0;
926                self.reopen_codec(target_qp)?;
927            }
928        }
929
930        Ok(())
931    }
932
933    /// Stays cheap enough for the pipeline to call on every single frame by re-opening the
934    /// codec only when a rate-control or framerate setting has actually changed — an unconditional
935    /// re-open here would force a needless IDR every frame and cripple the stream.
936    ///
937    /// The change test is deliberately narrow, to keep that guard tight: in CBR, a different target
938    /// bitrate or VBV multiplier; in any mode, a different target fps. When nothing changed it returns
939    /// without touching the codec. On a real change it caches the new fps / bitrate / VBV / keyframe
940    /// interval and calls `reopen_codec` carrying the **current** QP, so a CQP stream keeps its
941    /// quantizer across the change. `Err` means the re-open failed and the session no longer
942    /// has a codec context: the caller has to rebuild it.
943    pub fn reconfigure_rate(&mut self, settings: &RustCaptureSettings) -> Result<(), String> {
944        unsafe {
945            let mut changed = false;
946            if self.cbr_mode
947                && (settings.video_bitrate_kbps != self.current_bitrate_kbps
948                    || settings.video_vbv_multiplier != self.current_vbv_mult)
949            {
950                changed = true;
951            }
952            let new_fps = settings.target_fps.max(1.0) as i32;
953            if new_fps != self.fps {
954                changed = true;
955            }
956            if !changed {
957                return Ok(());
958            }
959            self.fps = new_fps;
960            self.current_bitrate_kbps = settings.video_bitrate_kbps;
961            self.current_vbv_mult = settings.video_vbv_multiplier;
962            self.current_kf_s = settings.keyframe_interval_s;
963            self.reopen_codec(self.current_qp)
964        }
965    }
966
967    /// Feeds each finished packet to two consumers that need different framing, which is why
968    /// this is more than a plain receive loop.
969    ///
970    /// The network `output` gets each packet wrapped in the 10-byte stripe header the client demuxer
971    /// expects, so a full frame can ride the exact same path as the striped modes — it is simply
972    /// described to the client as one full-height stripe: tag `0x04`, a keyframe flag (`0x01`/`0x00`
973    /// from `AV_PKT_FLAG_KEY`), the frame number as a big-endian `u16`, a `0` y-start (a whole frame
974    /// starts at the top), then width and height as big-endian `u16`s, followed by the raw Annex-B
975    /// payload.
976    /// `omit_stripe_headers` drops the header on the network path too, for a consumer that already
977    /// wants raw Annex-B. The loop drains `avcodec_receive_packet` until the encoder is empty,
978    /// unref'ing each packet before the next iteration.
979    unsafe fn collect_packet(&mut self, frame_number: u64, output: &mut Vec<u8>) {
980        while ff::avcodec_receive_packet(self.encoder_ctx, self.packet) == 0 {
981            let size = (*self.packet).size as usize;
982            let data = (*self.packet).data;
983            let is_key = ((*self.packet).flags & ff::AV_PKT_FLAG_KEY) != 0;
984
985            let header_sz = if self.omit_stripe_headers { 0 } else { 10 };
986            output.reserve(header_sz + size);
987            if !self.omit_stripe_headers {
988                output.push(0x04);
989                output.push(if is_key { 0x01 } else { 0x00 });
990                output.extend_from_slice(&(frame_number as u16).to_be_bytes());
991                output.extend_from_slice(&0u16.to_be_bytes());
992                output.extend_from_slice(&(self.width as u16).to_be_bytes());
993                output.extend_from_slice(&(self.height as u16).to_be_bytes());
994            }
995
996            let slice = std::slice::from_raw_parts(data, size);
997            output.extend_from_slice(slice);
998
999            ff::av_packet_unref(self.packet);
1000        }
1001    }
1002
1003    /// Encode one Wayland DRM-PRIME dmabuf by wrapping it in an FFmpeg DRM frame descriptor
1004    /// and pushing it through the filter graph, which `hwmap`s it to a VA surface and converts to
1005    /// the session's surface format before encode.
1006    ///
1007    /// 1. **Quantizer**: apply the requested `qp` through `update_qp` (hysteresis / CBR-aware).
1008    /// 2. **Descriptor**: allocate a zeroed `AVDRMFrameDescriptor` and populate it from the dmabuf —
1009    ///    one object per handle with a freshly `dup`'d fd (so FFmpeg owns independent fds), the object
1010    ///    size the fd reports (`lseek` to its end, which is the buffer object's allocation whatever
1011    ///    the tiling or padding), the format modifier, and one layer whose planes carry each plane's
1012    ///    offset and pitch. A single-handle multi-plane buffer points all planes at object 0.
1013    /// 3. **Ownership**: the dup'd fds live in a boxed `DmabufResources` handed to `av_buffer_create`
1014    ///    as `release_drm_frame`'s opaque, so FFmpeg closes them on teardown. If building that buffer
1015    ///    fails, `release_drm_frame` is called directly to clean up.
1016    /// 4. **Submit**: point `video_frame` at the descriptor, tag it DRM-PRIME with the DRM frames
1017    ///    context, and feed the graph. `av_buffersrc_add_frame` consumes the frame's refs only on
1018    ///    success; on error the frame is untouched, so `video_frame` is unref'd to release `buf[0]`
1019    ///    (which runs `release_drm_frame` and closes the fds) — no manual fd close, which would
1020    ///    double-close.
1021    /// 5. **Collect**: pull each converted frame from the sink, stamp `pict_type = I` when
1022    ///    `force_idr`, send it to the encoder, and drain packets via `collect_packet`.
1023    pub fn encode_dmabuf(
1024        &mut self,
1025        dmabuf: &Dmabuf,
1026        frame_number: u64,
1027        qp: u32,
1028        force_idr: bool,
1029    ) -> Result<Vec<u8>, String> {
1030        unsafe {
1031            self.update_qp(qp)?;
1032            self.require_open_codec()?;
1033
1034            let desc_size = mem::size_of::<AVDRMFrameDescriptor>();
1035            let desc_ptr = ff::av_mallocz(desc_size) as *mut AVDRMFrameDescriptor;
1036            if desc_ptr.is_null() {
1037                return Err("OOM".into());
1038            }
1039
1040            let mut resources = DmabufResources { fds: Vec::new() };
1041
1042            (*desc_ptr).nb_objects = dmabuf.handles().count() as i32;
1043            (*desc_ptr).nb_layers = 1;
1044
1045            for (i, (handle, _)) in dmabuf.handles().zip(dmabuf.offsets()).enumerate() {
1046                let fd = dup(handle.as_raw_fd());
1047                if fd < 0 {
1048                    for &dup_fd in &resources.fds {
1049                        close(dup_fd);
1050                    }
1051                    ff::av_free(desc_ptr as *mut c_void);
1052                    return Err("Failed to dup fd".into());
1053                }
1054                resources.fds.push(fd);
1055                (*desc_ptr).objects[i].fd = fd;
1056                // The dmabuf fd reports the object's real size; deriving it from stride and
1057                // height is wrong for tiled or compressed layouts and for any BO padded beyond
1058                // the image rows.
1059                let object_size = lseek(fd, 0, SEEK_END);
1060                if object_size <= 0 {
1061                    for &dup_fd in &resources.fds {
1062                        close(dup_fd);
1063                    }
1064                    ff::av_free(desc_ptr as *mut c_void);
1065                    return Err("Failed to query the dmabuf object size".into());
1066                }
1067                (*desc_ptr).objects[i].size = object_size as usize;
1068                (*desc_ptr).objects[i].format_modifier = u64::from(dmabuf.format().modifier);
1069            }
1070
1071            (*desc_ptr).layers[0].format = dmabuf.format().code as u32;
1072            (*desc_ptr).layers[0].nb_planes = dmabuf.num_planes() as i32;
1073
1074            for (i, (stride, offset)) in dmabuf.strides().zip(dmabuf.offsets()).enumerate() {
1075                (*desc_ptr).layers[0].planes[i].object_index = i as i32;
1076                (*desc_ptr).layers[0].planes[i].offset = offset as isize;
1077                (*desc_ptr).layers[0].planes[i].pitch = stride as isize;
1078            }
1079
1080            if dmabuf.handles().count() == 1 && dmabuf.num_planes() > 1 {
1081                for i in 0..dmabuf.num_planes() {
1082                    (*desc_ptr).layers[0].planes[i].object_index = 0;
1083                }
1084            }
1085
1086            ff::av_frame_unref(self.video_frame);
1087            (*self.video_frame).width = self.width;
1088            (*self.video_frame).height = self.height;
1089            (*self.video_frame).format = ff::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as i32;
1090            (*self.video_frame).data[0] = desc_ptr as *mut u8;
1091
1092            let opaque = Box::into_raw(Box::new(resources));
1093            let buf_ref = ff::av_buffer_create(
1094                desc_ptr as *mut u8,
1095                desc_size,
1096                Some(release_drm_frame),
1097                opaque as *mut c_void,
1098                0,
1099            );
1100
1101            if buf_ref.is_null() {
1102                release_drm_frame(opaque as *mut c_void, ptr::null_mut());
1103                ff::av_free(desc_ptr as *mut c_void);
1104                return Err("Failed to create buffer ref".into());
1105            }
1106            (*self.video_frame).buf[0] = buf_ref;
1107            (*self.video_frame).pts = frame_number as i64;
1108            (*self.video_frame).hw_frames_ctx = ff::av_buffer_ref(self.drm_frames_ctx);
1109
1110            if ff::av_buffersrc_add_frame(self.buffersrc_ctx, self.video_frame) < 0 {
1111                ff::av_frame_unref(self.video_frame);
1112                return Err("Failed to feed filter graph".into());
1113            }
1114
1115            let mut output = Vec::new();
1116            let mut filtered_frame = ff::av_frame_alloc();
1117
1118            while ff::av_buffersink_get_frame(self.buffersink_ctx, filtered_frame) >= 0 {
1119                if force_idr {
1120                    (*filtered_frame).pict_type = ff::AVPictureType::AV_PICTURE_TYPE_I;
1121                }
1122
1123                if ff::avcodec_send_frame(self.encoder_ctx, filtered_frame) < 0 {
1124                    ff::av_frame_free(&mut filtered_frame);
1125                    return Err("Failed to send frame to encoder".into());
1126                }
1127                ff::av_frame_unref(filtered_frame);
1128
1129                self.collect_packet(frame_number, &mut output);
1130            }
1131            ff::av_frame_free(&mut filtered_frame);
1132
1133            Ok(output)
1134        }
1135    }
1136
1137    /// Encode one host BGRA frame (X11 path) by staging it onto a VA surface and letting the
1138    /// GPU do the color convert — valid only on an encoder built with `new_host`.
1139    ///
1140    /// `bgra` is B,G,R,A in memory at `stride` bytes per row (padding allowed) and must hold at least
1141    /// `stride * height` bytes. The flow: apply `qp` via `update_qp`; allocate a fresh refcounted
1142    /// BGRA `video_frame` and copy the host rows in (the sole copy — plain data movement to a
1143    /// GPU-uploadable frame, clamped to `min(width*4, stride, dst_stride)` per row, **not** a
1144    /// colorspace conversion); feed the graph, where `hwupload` stages it onto a VA surface and
1145    /// `scale_vaapi` converts ARGB→YUV; then pull converted frames, stamp `pict_type = I` when
1146    /// `force_idr`, encode, and drain packets via `collect_packet`.
1147    pub fn encode_host_argb(
1148        &mut self,
1149        bgra: &[u8],
1150        stride: usize,
1151        frame_number: u64,
1152        qp: u32,
1153        force_idr: bool,
1154    ) -> Result<Vec<u8>, String> {
1155        unsafe {
1156            self.update_qp(qp)?;
1157            self.require_open_codec()?;
1158
1159            let h = self.height as usize;
1160            let needed = stride.checked_mul(h).ok_or("stride overflow")?;
1161            if bgra.len() < needed {
1162                return Err("Input buffer too small".into());
1163            }
1164
1165            ff::av_frame_unref(self.video_frame);
1166            (*self.video_frame).width = self.width;
1167            (*self.video_frame).height = self.height;
1168            (*self.video_frame).format = ff::AVPixelFormat::AV_PIX_FMT_BGRA as i32;
1169            if ff::av_frame_get_buffer(self.video_frame, 0) < 0 {
1170                return Err("Failed to allocate host BGRA frame".into());
1171            }
1172            let dst = (*self.video_frame).data[0];
1173            let dst_stride = (*self.video_frame).linesize[0] as usize;
1174            let row_bytes = (self.width as usize) * 4;
1175            let copy_bytes = row_bytes.min(stride).min(dst_stride);
1176            for row in 0..h {
1177                ptr::copy_nonoverlapping(
1178                    bgra.as_ptr().add(row * stride),
1179                    dst.add(row * dst_stride),
1180                    copy_bytes,
1181                );
1182            }
1183            (*self.video_frame).pts = frame_number as i64;
1184
1185            if ff::av_buffersrc_add_frame(self.buffersrc_ctx, self.video_frame) < 0 {
1186                ff::av_frame_unref(self.video_frame);
1187                return Err("Failed to feed filter graph".into());
1188            }
1189
1190            let mut output = Vec::new();
1191            let mut filtered_frame = ff::av_frame_alloc();
1192            while ff::av_buffersink_get_frame(self.buffersink_ctx, filtered_frame) >= 0 {
1193                if force_idr {
1194                    (*filtered_frame).pict_type = ff::AVPictureType::AV_PICTURE_TYPE_I;
1195                }
1196                if ff::avcodec_send_frame(self.encoder_ctx, filtered_frame) < 0 {
1197                    ff::av_frame_free(&mut filtered_frame);
1198                    return Err("Failed to send frame to encoder".into());
1199                }
1200                ff::av_frame_unref(filtered_frame);
1201                self.collect_packet(frame_number, &mut output);
1202            }
1203            ff::av_frame_free(&mut filtered_frame);
1204
1205            Ok(output)
1206        }
1207    }
1208
1209    /// Encode already-planar pixels by uploading them straight to a VA surface, bypassing the
1210    /// filter graph (there is no color convert to do).
1211    ///
1212    /// `pixels` carries whichever layout the session negotiated: NV12 (`w*h` of Y then `w*h/2` of
1213    /// interleaved UV) for a 4:2:0 session, or planar I444 (`w*h` each of Y, U, V) for a 4:4:4 one.
1214    /// The `sw_frame` is pointed at those planes without copying, except on a driver whose only
1215    /// 4:4:4 surface is packed, where `pack_i444_as_vuyx` stages the frame into a reused buffer
1216    /// first. A fresh `hw_frame` is pulled from the encoder pool (the frame is unref'd first, or the
1217    /// prior surface would leak), `av_hwframe_transfer_data` copies CPU→GPU, and the `sw_frame` is
1218    /// released. Keyframes are forced through `pict_type = I` (`AV_PKT_FLAG_KEY` is a *packet* flag,
1219    /// unusable on a frame); otherwise `pict_type = NONE`. The GPU frame is sent to the encoder and
1220    /// packets are drained via `collect_packet`.
1221    pub fn encode_raw(
1222        &mut self,
1223        pixels: &[u8],
1224        frame_number: u64,
1225        qp: u32,
1226        force_idr: bool,
1227    ) -> Result<Vec<u8>, String> {
1228        unsafe {
1229            self.update_qp(qp)?;
1230            self.require_open_codec()?;
1231
1232            let width = self.width as usize;
1233            let height = self.height as usize;
1234            let plane = width * height;
1235            let required_size = if self.is_fullcolor() { plane * 3 } else { plane + plane / 2 };
1236
1237            if pixels.len() < required_size {
1238                return Err("Input buffer too small".into());
1239            }
1240
1241            ff::av_frame_unref(self.sw_frame);
1242            (*self.sw_frame).format = self.sw_format as i32;
1243            (*self.sw_frame).width = self.width;
1244            (*self.sw_frame).height = self.height;
1245
1246            match self.sw_format {
1247                ff::AVPixelFormat::AV_PIX_FMT_YUV444P => {
1248                    for i in 0..3 {
1249                        (*self.sw_frame).data[i] = pixels.as_ptr().add(i * plane) as *mut u8;
1250                        (*self.sw_frame).linesize[i] = self.width;
1251                    }
1252                }
1253                ff::AVPixelFormat::AV_PIX_FMT_NV12 => {
1254                    (*self.sw_frame).data[0] = pixels.as_ptr() as *mut u8;
1255                    (*self.sw_frame).linesize[0] = self.width;
1256                    (*self.sw_frame).data[1] = pixels.as_ptr().add(plane) as *mut u8;
1257                    (*self.sw_frame).linesize[1] = self.width;
1258                }
1259                _ => {
1260                    pack_i444_as_vuyx(pixels, &mut self.packed_444, plane);
1261                    (*self.sw_frame).data[0] = self.packed_444.as_mut_ptr();
1262                    (*self.sw_frame).linesize[0] = self.width * 4;
1263                }
1264            }
1265
1266            ff::av_frame_unref(self.hw_frame);
1267            if ff::av_hwframe_get_buffer((*self.encoder_ctx).hw_frames_ctx, self.hw_frame, 0) < 0 {
1268                return Err("Failed to allocate HW frame from the encoder pool".into());
1269            }
1270            (*self.hw_frame).width = self.width;
1271            (*self.hw_frame).height = self.height;
1272
1273            if ff::av_hwframe_transfer_data(self.hw_frame, self.sw_frame, 0) < 0 {
1274                return Err("Failed to upload frame to GPU".into());
1275            }
1276
1277            ff::av_frame_unref(self.sw_frame);
1278
1279            (*self.hw_frame).pts = frame_number as i64;
1280            if force_idr {
1281                (*self.hw_frame).pict_type = ff::AVPictureType::AV_PICTURE_TYPE_I;
1282            } else {
1283                (*self.hw_frame).pict_type = ff::AVPictureType::AV_PICTURE_TYPE_NONE;
1284            }
1285
1286            if ff::avcodec_send_frame(self.encoder_ctx, self.hw_frame) < 0 {
1287                return Err("Error sending frame to encoder".into());
1288            }
1289
1290            let mut output = Vec::new();
1291            self.collect_packet(frame_number, &mut output);
1292
1293            Ok(output)
1294        }
1295    }
1296}
1297
1298#[cfg(test)]
1299mod fullcolor_tests {
1300    use super::*;
1301
1302    /// Planar I444 repacks to V, U, Y per pixel with an opaque fourth byte. The byte order is
1303    /// checked against FFmpeg's own yuv444p→vuyx conversion, which produces exactly these values
1304    /// for this input.
1305    #[test]
1306    fn packs_i444_as_vuyx() {
1307        let plane = 4;
1308        let y: Vec<u8> = (0..plane).map(|i| (i * 3 + 1) as u8).collect();
1309        let u: Vec<u8> = (0..plane).map(|i| (i * 5 + 60) as u8).collect();
1310        let v: Vec<u8> = (0..plane).map(|i| (i * 7 + 130) as u8).collect();
1311        let planar: Vec<u8> = y.iter().chain(&u).chain(&v).copied().collect();
1312
1313        let mut packed = Vec::new();
1314        pack_i444_as_vuyx(&planar, &mut packed, plane);
1315        assert_eq!(
1316            packed,
1317            vec![
1318                130, 60, 1, 255, //
1319                137, 65, 4, 255, //
1320                144, 70, 7, 255, //
1321                151, 75, 10, 255,
1322            ]
1323        );
1324
1325        // The scratch buffer is reused across frames, so a second call must fully overwrite it.
1326        pack_i444_as_vuyx(&vec![0u8; plane * 3], &mut packed, plane);
1327        assert_eq!(packed, [0, 0, 0, 255].repeat(plane));
1328    }
1329
1330    /// The planar surface wins whenever a device carries it, because `encode_raw` uploads the
1331    /// readback path's I444 buffer to it without a repack; the packed one is taken only when it is
1332    /// the sole 4:4:4 format on offer, and a device carrying neither yields nothing.
1333    #[test]
1334    fn prefers_planar_fullcolor_format() {
1335        use ff::AVPixelFormat::*;
1336        assert_eq!(preferred_fullcolor_format(&[AV_PIX_FMT_NV12]), None);
1337        assert_eq!(
1338            preferred_fullcolor_format(&[AV_PIX_FMT_NV12, AV_PIX_FMT_VUYX]),
1339            Some(AV_PIX_FMT_VUYX)
1340        );
1341        assert_eq!(
1342            preferred_fullcolor_format(&[AV_PIX_FMT_VUYX, AV_PIX_FMT_YUV444P]),
1343            Some(AV_PIX_FMT_YUV444P)
1344        );
1345    }
1346
1347    /// The names handed to `scale_vaapi` have to be the ones FFmpeg parses, or the filter chain
1348    /// fails to build at a point that looks like a driver fault.
1349    #[test]
1350    fn fullcolor_formats_have_parseable_names() {
1351        for fmt in FULLCOLOR_SW_FORMATS {
1352            let name = pix_fmt_name(fmt);
1353            let round_trip =
1354                unsafe { ff::av_get_pix_fmt(CString::new(name.clone()).unwrap().as_ptr()) };
1355            assert_eq!(round_trip, fmt, "{name} did not parse back");
1356        }
1357    }
1358
1359    /// Construction either stands a session up or says why it could not; a half-built encoder is
1360    /// the one outcome that must never reach a caller. When a 4:4:4 request does succeed the
1361    /// session really is 4:4:4, and when 4:2:0 is asked for it never quietly becomes something
1362    /// else — callers size the buffer they hand `encode_raw` from that answer, so a silent
1363    /// downgrade would hand the encoder planes it does not read. Runs everywhere: a host without a
1364    /// VA-API device exercises the error path, one with a device exercises the agreement.
1365    #[test]
1366    fn negotiated_chroma_matches_what_was_asked_for() {
1367        let mut settings = RustCaptureSettings {
1368            width: 128,
1369            height: 128,
1370            output_mode: 1,
1371            video_fullcolor: true,
1372            ..Default::default()
1373        };
1374        match VaapiEncoder::new_host(&settings) {
1375            Ok(enc) => assert!(enc.is_fullcolor(), "4:4:4 session reports 4:2:0"),
1376            Err(e) => assert!(!e.is_empty(), "refusal must carry a reason"),
1377        }
1378        settings.video_fullcolor = false;
1379        match VaapiEncoder::new_host(&settings) {
1380            Ok(enc) => assert!(!enc.is_fullcolor(), "4:2:0 session reports 4:4:4"),
1381            Err(e) => assert!(!e.is_empty(), "refusal must carry a reason"),
1382        }
1383    }
1384
1385    /// Walk a real device's reported format list rather than a fixture: VA-API is absent on CI and
1386    /// on GPU hosts without a render node, but any FFmpeg hardware device answers the same
1387    /// `av_hwdevice_get_hwframe_constraints` call, so CUDA stands in to prove the walk terminates
1388    /// on the format-list sentinel and that the pick comes back from what the device actually
1389    /// reported. Ignored by default (needs a working hardware device).
1390    #[test]
1391    #[ignore]
1392    fn gpu_fullcolor_format_follows_device_constraints() {
1393        unsafe {
1394            let mut dev: *mut ff::AVBufferRef = ptr::null_mut();
1395            assert!(
1396                ff::av_hwdevice_ctx_create(
1397                    &mut dev,
1398                    ff::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
1399                    ptr::null(),
1400                    ptr::null_mut(),
1401                    0,
1402                ) >= 0,
1403                "no CUDA device"
1404            );
1405
1406            let constraints = ff::av_hwdevice_get_hwframe_constraints(dev, ptr::null());
1407            assert!(!constraints.is_null());
1408            let mut carried = Vec::new();
1409            let mut fmt = (*constraints).valid_sw_formats;
1410            while !fmt.is_null() && *fmt != ff::AVPixelFormat::AV_PIX_FMT_NONE {
1411                carried.push(*fmt);
1412                fmt = fmt.add(1);
1413            }
1414            let mut owned = constraints;
1415            ff::av_hwframe_constraints_free(&mut owned);
1416
1417            let picked = fullcolor_sw_format(dev);
1418            println!(
1419                "device carries {} formats; 4:4:4 pick: {:?}",
1420                carried.len(),
1421                picked.map(pix_fmt_name)
1422            );
1423            assert_eq!(picked, preferred_fullcolor_format(&carried));
1424            if let Some(fmt) = picked {
1425                assert!(carried.contains(&fmt), "picked a format the device never reported");
1426            }
1427            ff::av_buffer_unref(&mut dev);
1428        }
1429    }
1430}
1431
1432#[cfg(test)]
1433mod graph_ordering_tests {
1434    use super::*;
1435
1436    /// Build the same shape of graph as `new_impl`'s host path — explicit buffersrc/sink
1437    /// endpoints around an `hwupload,…` chain — against a CUDA device, to exercise the device-attach
1438    /// ordering without needing a VA-API device.
1439    ///
1440    /// `hwupload` is the generic filter VA-API also uses, so CUDA stands in on a box that has no VA
1441    /// device. `staged = true` builds via the segment API (parse → create filters → attach device →
1442    /// init/link); `staged = false` uses the one-shot `avfilter_graph_parse_ptr` with the device
1443    /// attached only afterwards. The helper then configures the graph and pushes one 64×64 BGRA frame
1444    /// through it, returning `Err` unless a frame actually flows out — proving the graph passes
1445    /// pixels, not merely that it configured.
1446    unsafe fn build_hwupload_graph(staged: bool) -> Result<(), String> {
1447        let mut dev: *mut ff::AVBufferRef = ptr::null_mut();
1448        if ff::av_hwdevice_ctx_create(
1449            &mut dev,
1450            ff::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
1451            ptr::null(),
1452            ptr::null_mut(),
1453            0,
1454        ) < 0
1455        {
1456            return Err("no CUDA device".into());
1457        }
1458
1459        let graph = ff::avfilter_graph_alloc();
1460        let mut src: *mut ff::AVFilterContext = ptr::null_mut();
1461        let args = CString::new("video_size=64x64:pix_fmt=bgra:time_base=1/30").unwrap();
1462        let r = ff::avfilter_graph_create_filter(
1463            &mut src,
1464            ff::avfilter_get_by_name(CString::new("buffer").unwrap().as_ptr()),
1465            CString::new("in").unwrap().as_ptr(),
1466            args.as_ptr(),
1467            ptr::null_mut(),
1468            graph,
1469        );
1470        assert!(r >= 0, "buffersrc create");
1471        let mut sink: *mut ff::AVFilterContext = ptr::null_mut();
1472        let r = ff::avfilter_graph_create_filter(
1473            &mut sink,
1474            ff::avfilter_get_by_name(CString::new("buffersink").unwrap().as_ptr()),
1475            CString::new("out").unwrap().as_ptr(),
1476            ptr::null(),
1477            ptr::null_mut(),
1478            graph,
1479        );
1480        assert!(r >= 0, "buffersink create");
1481
1482        let desc = CString::new("hwupload,hwdownload,format=bgra").unwrap();
1483        let result: Result<(), String> = if staged {
1484            let mut seg: *mut ff::AVFilterGraphSegment = ptr::null_mut();
1485            let mut ins: *mut ff::AVFilterInOut = ptr::null_mut();
1486            let mut outs: *mut ff::AVFilterInOut = ptr::null_mut();
1487            let ok = ff::avfilter_graph_segment_parse(graph, desc.as_ptr(), 0, &mut seg) >= 0
1488                && ff::avfilter_graph_segment_create_filters(seg, 0) >= 0
1489                && {
1490                    for i in 0..(*graph).nb_filters {
1491                        let f = *(*graph).filters.add(i as usize);
1492                        if (*f).hw_device_ctx.is_null() {
1493                            (*f).hw_device_ctx = ff::av_buffer_ref(dev);
1494                        }
1495                    }
1496                    ff::avfilter_graph_segment_apply(seg, 0, &mut ins, &mut outs) >= 0
1497                }
1498                && !ins.is_null()
1499                && !outs.is_null()
1500                && ff::avfilter_link(src, 0, (*ins).filter_ctx, (*ins).pad_idx as u32) >= 0
1501                && ff::avfilter_link((*outs).filter_ctx, (*outs).pad_idx as u32, sink, 0) >= 0;
1502            ff::avfilter_inout_free(&mut ins);
1503            ff::avfilter_inout_free(&mut outs);
1504            ff::avfilter_graph_segment_free(&mut seg);
1505            if ok { Ok(()) } else { Err("segment build failed".into()) }
1506        } else {
1507            let mut inputs = ff::avfilter_inout_alloc();
1508            let mut outputs = ff::avfilter_inout_alloc();
1509            (*inputs).name = ff::av_strdup(CString::new("in").unwrap().as_ptr());
1510            (*inputs).filter_ctx = src;
1511            (*inputs).pad_idx = 0;
1512            (*inputs).next = ptr::null_mut();
1513            (*outputs).name = ff::av_strdup(CString::new("out").unwrap().as_ptr());
1514            (*outputs).filter_ctx = sink;
1515            (*outputs).pad_idx = 0;
1516            (*outputs).next = ptr::null_mut();
1517            let r = ff::avfilter_graph_parse_ptr(
1518                graph,
1519                desc.as_ptr(),
1520                &mut outputs,
1521                &mut inputs,
1522                ptr::null_mut(),
1523            );
1524            ff::avfilter_inout_free(&mut inputs);
1525            ff::avfilter_inout_free(&mut outputs);
1526            if r < 0 {
1527                Err(format!("parse failed before the attach loop could run: {}", ff_err_str(r)))
1528            } else {
1529                for i in 0..(*graph).nb_filters {
1530                    let f = *(*graph).filters.add(i as usize);
1531                    if (*f).hw_device_ctx.is_null() {
1532                        (*f).hw_device_ctx = ff::av_buffer_ref(dev);
1533                    }
1534                }
1535                Ok(())
1536            }
1537        };
1538
1539        let result = result.and_then(|()| {
1540            let r = ff::avfilter_graph_config(graph, ptr::null_mut());
1541            if r < 0 { Err(format!("config failed: {}", ff_err_str(r))) } else { Ok(()) }
1542        });
1543
1544        let result = result.and_then(|()| {
1545            let frame = ff::av_frame_alloc();
1546            (*frame).format = ff::AVPixelFormat::AV_PIX_FMT_BGRA as i32;
1547            (*frame).width = 64;
1548            (*frame).height = 64;
1549            if ff::av_frame_get_buffer(frame, 0) < 0 {
1550                return Err("frame alloc".into());
1551            }
1552            for y in 0..64 {
1553                let row = (*frame).data[0].add(y * (*frame).linesize[0] as usize);
1554                std::ptr::write_bytes(row, 0x80, 64 * 4);
1555            }
1556            let mut fr = frame;
1557            let ok = ff::av_buffersrc_add_frame(src, fr) >= 0 && {
1558                let out = ff::av_frame_alloc();
1559                let got = ff::av_buffersink_get_frame(sink, out) >= 0
1560                    && (*out).width == 64
1561                    && !(*out).data[0].is_null();
1562                let mut o = out;
1563                ff::av_frame_free(&mut o);
1564                got
1565            };
1566            ff::av_frame_free(&mut fr);
1567            if ok { Ok(()) } else { Err("frame did not flow through the graph".into()) }
1568        });
1569
1570        let mut g = graph;
1571        ff::avfilter_graph_free(&mut g);
1572        ff::av_buffer_unref(&mut dev);
1573        result
1574    }
1575
1576    /// Verify the device-attach ordering the host filter graph depends on: on the same
1577    /// machine, the staged segment build must pass pixels end-to-end while the one-shot parser must
1578    /// fail. The one-shot parser initializes `hwupload` during the parse — before any device-attach
1579    /// loop can run — so it never gets a device and would force a software fallback, which is exactly
1580    /// why `new_impl` stages the graph. Ignored by default (needs a working hardware device).
1581    #[test]
1582    #[ignore]
1583    fn gpu_hwupload_device_attach_ordering() {
1584        unsafe {
1585            let old = build_hwupload_graph(false);
1586            let new = build_hwupload_graph(true);
1587            println!("one-shot parse+attach-after: {old:?}");
1588            println!("staged segment build:        {new:?}");
1589            assert!(new.is_ok(), "staged build must work: {new:?}");
1590            assert!(old.is_err(), "expected the one-shot ordering to fail on this FFmpeg");
1591        }
1592    }
1593}