Skip to main content

codec/encode/
mod.rs

1#[cfg(feature = "amd")]
2pub mod amf;
3#[cfg(not(feature = "amd"))]
4#[path = "amf_stub.rs"]
5pub mod amf;
6#[cfg(feature = "ffmpeg")]
7pub mod ffmpeg_enc;
8#[cfg(feature = "nvidia")]
9pub mod nvenc;
10#[cfg(not(feature = "nvidia"))]
11#[path = "nvenc_stub.rs"]
12pub mod nvenc;
13#[cfg(feature = "qsv")]
14pub mod qsv;
15#[cfg(not(feature = "qsv"))]
16#[path = "qsv_stub.rs"]
17pub mod qsv;
18pub mod tuning;
19// rav1e CPU encoder + Vulkan video encoder were deleted 2026-05-08
20// per the GPU-only encoding directive. Production hosts must have
21// AV1 silicon (NVIDIA Ada+ / AMD RDNA3+ / Intel Arc); jobs that
22// land on a host without one of those vendor-native paths now
23// hard-fail at encoder construction.
24
25use crate::frame::{ColorMetadata, PixelFormat, VideoFrame};
26use crate::gpu;
27use anyhow::Result;
28use bytes::Bytes;
29
30pub use tuning::{QualityTarget, SpeedTier};
31
32/// Pick a GPU for a given vendor, honouring an explicit `gpu_index`
33/// request when set. Returns `None` if no vendor GPU is present OR
34/// the requested index belongs to a different vendor.
35///
36/// - `requested = Some(idx)`: look up the GPU with `GpuDevice.index == idx`.
37///   If it exists AND matches `vendor`, return it. If it exists but is
38///   a different vendor (e.g. caller pinned variant to NVIDIA slot 2
39///   but we're evaluating the AMD fallback branch), return `None` so
40///   dispatch falls through to the next tier — the other vendor tiers
41///   will see this same `requested` index and match it there.
42/// - `requested = None`: first-of-vendor (original pre-multi-GPU
43///   behaviour, single-GPU hosts unaffected).
44fn pick_vendor_device(
45    gpus: &[gpu::GpuDevice],
46    vendor: gpu::GpuVendor,
47    requested: Option<u32>,
48) -> Option<&gpu::GpuDevice> {
49    match requested {
50        Some(idx) => gpus.iter().find(|g| g.index == idx && g.vendor == vendor),
51        None => gpus.iter().find(|g| g.vendor == vendor),
52    }
53}
54
55/// Shared truthy-string parse for env flags — mirrors the decode-side
56/// `env_flag_truthy` so `DISABLE_FFMPEG=1` / `true` / `yes` / `on`
57/// all work identically across decode + encode dispatch.
58#[cfg(feature = "ffmpeg")]
59fn ffmpeg_disable_flag() -> bool {
60    match std::env::var("DISABLE_FFMPEG") {
61        Ok(v) => {
62            let v = v.to_ascii_lowercase();
63            matches!(v.as_str(), "1" | "true" | "yes" | "on" | "y" | "t")
64        }
65        Err(_) => false,
66    }
67}
68
69pub trait Encoder: Send {
70    fn send_frame(&mut self, frame: &VideoFrame) -> Result<()>;
71    fn flush(&mut self) -> Result<()>;
72    fn receive_packet(&mut self) -> Result<Option<EncodedPacket>>;
73}
74
75#[derive(Debug, Clone)]
76pub struct EncodedPacket {
77    pub data: Bytes,
78    pub pts: u64,
79    pub is_keyframe: bool,
80}
81
82/// Encoder configuration.
83///
84/// Prefer `target` + `tier` — `quality` and `speed_preset` are the
85/// legacy per-encoder escape hatches and are kept so existing callers
86/// compile. When `quality` is set to its sentinel (u8::MAX) the
87/// adapter derives the quantizer from `target` instead. Same for
88/// `speed_preset` (u8::MAX sentinel → derive from `tier`).
89#[derive(Debug, Clone)]
90pub struct EncoderConfig {
91    pub width: u32,
92    pub height: u32,
93    pub frame_rate: f64,
94    /// Legacy escape hatch. `u8::MAX` means "derive from `target`".
95    /// Otherwise: rav1e → used as quantizer 0-255; NVENC → scaled to
96    /// its CQ range.
97    pub quality: u8,
98    /// Legacy escape hatch. `u8::MAX` means "derive from `tier`".
99    pub speed_preset: u8,
100    pub keyframe_interval: u32,
101    /// Perceptual quality target. Defaults to `Standard` (VMAF ~90).
102    pub target: QualityTarget,
103    /// Speed tier (Draft / Standard / Archive). Defaults to `Standard`.
104    pub tier: SpeedTier,
105    /// Thread budget for this encoder instance. `0` means "use all cores"
106    /// (rav1e default). When the pipeline runs N variants in parallel it
107    /// should set this to `num_cpus / N` to avoid oversubscribing rayon
108    /// workers across concurrent rav1e encoders.
109    pub threads: usize,
110    /// Input pixel format. Drives the encoder's bit-depth dispatch
111    /// (Squad-19 rav1e CPU + Squad-22 NVENC/AMF/QSV, roadmap #5).
112    /// `Yuv420p` → 8-bit AV1 Profile 0; `Yuv420p10le` → 10-bit AV1
113    /// Profile 0 (10-bit 4:2:0 is allowed in Profile 0 per AV1 §5.5.2
114    /// — `seq_profile=0`, `seq_color_config` emits `high_bitdepth=1`,
115    /// `twelve_bit=0`). HW backends pick the matching surface fourcc:
116    /// NVENC `YUV420_10BIT`, AMF `P010`, QSV `P010` + `BitDepthLuma=10`.
117    /// Set once at encoder construction; flipping mid-session requires
118    /// reinitialising. The muxer's `pixi`-equivalent + AV1 sequence
119    /// header in `av1C` carry the bit depth so HDR-capable browsers
120    /// see 10-bit signaling.
121    pub pixel_format: PixelFormat,
122    /// Source color metadata. Encoders write
123    /// `color_primaries` / `transfer_characteristics` /
124    /// `matrix_coefficients` / `color_range` into the AV1 sequence
125    /// header so HDR-capable players see the correct PQ/HLG transfer
126    /// + BT.2020 primaries straight off the bitstream — not just the
127    /// container `colr` atom (Squad-19 rav1e + Squad-22 HW; complements
128    /// Squad-18's container-side colr nclx writer). Without bitstream
129    /// signalling, players that prefer the OBU header over the box
130    /// (e.g. Chromium video framework) would silently fall back to
131    /// BT.709. Defaults to SDR BT.709.
132    pub color_metadata: ColorMetadata,
133    /// Explicit GPU device index for HW encoders on multi-GPU hosts.
134    /// When `Some(idx)`, `select_encoder` binds NVENC / AMF / QSV /
135    /// Vulkan AV1 / FFmpeg hwaccel encoders to the device with
136    /// `GpuDevice.index == idx`. When `None` (default), the first
137    /// GPU of each vendor is used — matches the original pre-multi-GPU
138    /// behaviour.
139    ///
140    /// Pipeline `transcode::run` assigns `variant_idx % devices.len()`
141    /// per variant so a multi-variant job on a multi-GPU host spreads
142    /// work across devices, matching the Python original's
143    /// `ThreadPoolExecutor(max_workers=device_count)` per-variant fan-out.
144    pub gpu_index: Option<u32>,
145    /// Explicit vendor pin for HW encoder dispatch. When `Some(v)`,
146    /// `select_encoder` skips the NVIDIA → AMD → Intel preference
147    /// chain and goes DIRECTLY to the encoder backend matching `v`
148    /// (NVENC for Nvidia, AMF for Amd, QSV for Intel). Used by the
149    /// CMAF orchestrator to honor the GpuPool's lease — when the
150    /// pool hands out an Intel slot (because the NVIDIA card is
151    /// already encoding), this field tells the factory to dispatch
152    /// to QSV instead of falling back to NVENC and pinning every
153    /// variant to the NVIDIA card.
154    ///
155    /// `None` (default) preserves the legacy NVIDIA-first chain so
156    /// CPU-only paths + tests + non-pool callers behave unchanged.
157    pub gpu_vendor: Option<gpu::GpuVendor>,
158    /// Prefer **constant-QP** rate control over the bitrate/quality default.
159    /// Set by the multi-GPU single-file path under `ChunkSeamMode::ParallelConstQp`
160    /// so independently-encoded chunks have a flat quality across the stitched
161    /// seams. On NVENC this selects `RateControlMode::ConstQp` (the wrapper then
162    /// uses the preset's default QP — the `target` bitrate mapping is skipped).
163    /// AMD/QSV already encode constant-quality, so this is a no-op for them.
164    pub constant_qp: bool,
165}
166
167/// Sentinel meaning "derive from `target` or `tier`".
168pub const AUTO_FROM_TARGET: u8 = u8::MAX;
169
170impl Default for EncoderConfig {
171    fn default() -> Self {
172        Self {
173            width: 1920,
174            height: 1080,
175            frame_rate: 30.0,
176            quality: AUTO_FROM_TARGET,
177            speed_preset: AUTO_FROM_TARGET,
178            keyframe_interval: 240,
179            target: QualityTarget::Standard,
180            tier: SpeedTier::Standard,
181            threads: 0,
182            // 8-bit SDR baseline — keeps every existing
183            // `EncoderConfig { ..default() }` literal compiling and
184            // behaving unchanged. 10-bit callers (Squad-19 rav1e or
185            // Squad-22 HW backends) explicitly opt in by setting
186            // `pixel_format = Yuv420p10le` and populating
187            // `color_metadata` from the source.
188            pixel_format: PixelFormat::Yuv420p,
189            color_metadata: ColorMetadata::default(),
190            gpu_index: None,
191            gpu_vendor: None,
192            constant_qp: false,
193        }
194    }
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum EncoderBackend {
199    Nvenc,
200    Amf,
201    Qsv,
202}
203
204/// What output formats an encoder path can produce. AV1 here is 4:2:0 only;
205/// 10-bit output is the web-safe AV1 Main profile (4:2:0 10-bit), HDR-tagged at
206/// the container level (`colr`/`mdcv`/`clli`), not the wide-gamut professional
207/// profiles.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub struct OutputCaps {
210    /// Highest luma bit depth the path can encode (8 or 10).
211    pub max_bit_depth: u8,
212    /// Can produce HDR (PQ/HLG + BT.2020) output — i.e. 10-bit AV1 + the muxer's
213    /// HDR color atoms.
214    pub hdr: bool,
215}
216
217/// Output capabilities of a specific hardware backend. All three do 10-bit AV1,
218/// so they can produce HDR without the `ffmpeg` feature: NVENC via
219/// `Yuv420_10bit`, AMF via `P010`, and QSV via the in-repo oneVPL P010 path
220/// ([`qsv_p010`]).
221pub fn backend_output_caps(backend: EncoderBackend) -> OutputCaps {
222    match backend {
223        EncoderBackend::Nvenc | EncoderBackend::Amf | EncoderBackend::Qsv => {
224            OutputCaps { max_bit_depth: 10, hdr: true }
225        }
226    }
227}
228
229/// Output capabilities of **this build** — the union over every compiled
230/// encoder path. 10-bit + HDR comes from NVENC (`nvidia`), AMF (`amd`), QSV
231/// (`qsv`, via the in-repo P010 path), or the `ffmpeg` software/hwaccel
232/// encoders; a build with no encoder feature is 8-bit. Callers (e.g. rivet's
233/// `OutputSpec::validate`) use this to reject a format the build can't produce.
234pub fn build_output_caps() -> OutputCaps {
235    #[cfg(any(
236        feature = "ffmpeg",
237        feature = "nvidia",
238        feature = "amd",
239        feature = "qsv"
240    ))]
241    {
242        return OutputCaps { max_bit_depth: 10, hdr: true };
243    }
244    #[allow(unreachable_code)]
245    OutputCaps { max_bit_depth: 8, hdr: false }
246}
247
248/// AV1-encode backends compiled into this build, in dispatch-preference order.
249pub fn encode_backends() -> Vec<&'static str> {
250    let mut v = Vec::new();
251    if cfg!(feature = "nvidia") {
252        v.push("nvenc");
253    }
254    if cfg!(feature = "amd") {
255        v.push("amf");
256    }
257    if cfg!(feature = "qsv") {
258        v.push("qsv");
259    }
260    if cfg!(feature = "ffmpeg") {
261        v.push("ffmpeg");
262    }
263    v
264}
265
266/// Construct the QSV encoder. The hand-rolled oneVPL encoder (`qsv.rs`) handles
267/// both 8-bit (NV12) and 10-bit (P010) AV1; under `not(qsv)` this hits the stub.
268fn make_qsv_encoder(config: EncoderConfig, gpu_index: u32) -> Result<Box<dyn Encoder>> {
269    Ok(Box::new(qsv::QsvEncoder::new(config, gpu_index)?))
270}
271
272/// Create the best available AV1 encoder.
273///
274/// Priority: NVENC (Ada+) → AMF (RDNA3+) → QSV (Arc / Meteor Lake+).
275///
276/// GPU-only — there is no CPU fallback. Hosts without AV1-encode
277/// silicon hard-fail at construction. The previous rav1e CPU and
278/// Vulkan Video tiers were removed 2026-05-08: rav1e on Archive
279/// preset doesn't keep up with real-time throughput at 4K and the
280/// Vulkan-encode binding never made it past scaffolding.
281/// All backends compiled in; availability checked at runtime.
282pub fn select_encoder(
283    config: EncoderConfig,
284    preferred: Option<EncoderBackend>,
285) -> Result<Box<dyn Encoder>> {
286    let gpus = gpu::detect_gpus();
287
288    if let Some(backend) = preferred {
289        return create_backend(backend, config, &gpus);
290    }
291
292    // Tier 0 (feature-gated): FFmpeg AV1 encoder (libavcodec's
293    // av1_nvenc / av1_amf / av1_qsv / av1_vaapi / libsvtav1 /
294    // libaom-av1 / librav1e probe chain). When the `ffmpeg` feature
295    // is built and DISABLE_FFMPEG is not set, FFmpeg is the first
296    // encoder tried for every host — one interface covers every GPU
297    // vendor AND the CPU fallbacks. The native NVENC / AMF / QSV /
298    // Vulkan AV1 / rav1e paths below remain as failover when the
299    // FFmpeg probe chain errors. See `docs/hw-matrix.md`.
300    #[cfg(feature = "ffmpeg")]
301    {
302        if !ffmpeg_disable_flag() {
303            match ffmpeg_enc::FfmpegEncoder::new(config.clone()) {
304                Ok(enc) => {
305                    tracing::info!(
306                        backend = "ffmpeg",
307                        av1_encoder = enc.engaged(),
308                        "FFmpeg primary encoder dispatch engaged"
309                    );
310                    return Ok(Box::new(enc));
311                }
312                Err(e) => {
313                    tracing::warn!(
314                        error = %e,
315                        "FFmpeg AV1 encoder chain exhausted; falling through to native backends"
316                    );
317                }
318            }
319        } else {
320            tracing::debug!("DISABLE_FFMPEG set; skipping FFmpeg encoder dispatch");
321        }
322    }
323
324    // Vendor-pin shortcut: when the caller has already chosen which
325    // GPU to use (CMAF orchestrator does this via the GpuPool lease,
326    // 2026-05-03), dispatch DIRECTLY to that vendor's backend
327    // instead of running the NVIDIA-first preference chain.
328    // Without this, a host with both NVIDIA + Intel GPUs always
329    // routed every variant to NVENC because the chain hits
330    // `pick_vendor_device(Nvidia, ...)` first; the Arc sat idle even
331    // when NVENC sessions were saturated. CPU rav1e remains the
332    // last-resort if hardware init fails on the pinned vendor.
333    if let Some(pinned) = config.gpu_vendor {
334        if let Some(dev) = pick_vendor_device(&gpus, pinned, config.gpu_index) {
335            if gpu::supports_av1_encode(dev) {
336                let attempt = match pinned {
337                    gpu::GpuVendor::Nvidia => nvenc::NvencEncoder::new(config.clone(), dev.index)
338                        .map(|e| Box::new(e) as Box<dyn Encoder>),
339                    gpu::GpuVendor::Amd => amf::AmfEncoder::new(config.clone(), dev.index)
340                        .map(|e| Box::new(e) as Box<dyn Encoder>),
341                    gpu::GpuVendor::Intel => make_qsv_encoder(config.clone(), dev.index),
342                };
343                return match attempt {
344                    Ok(enc) => {
345                        tracing::info!(
346                            gpu_name = %dev.name,
347                            gpu_index = dev.index,
348                            vendor = ?pinned,
349                            "using vendor-pinned AV1 hardware encoder (lease-driven dispatch)"
350                        );
351                        Ok(enc)
352                    }
353                    Err(e) => {
354                        // GPU-only directive (2026-05-08): the caller
355                        // pinned a vendor for a reason (lease-driven
356                        // GPU pool dispatch). Init failure is a hard
357                        // error — there is no CPU fallback. Surface
358                        // the underlying driver error so the worker
359                        // can report it on the failed-job event.
360                        Err(anyhow::anyhow!(
361                            "vendor-pinned AV1 encoder init failed (vendor={pinned:?}, gpu={}, idx={}): {e}",
362                            dev.name,
363                            dev.index,
364                        ))
365                    }
366                };
367            }
368            return Err(anyhow::anyhow!(
369                "vendor-pinned GPU lacks AV1 encode silicon (vendor={pinned:?}, gpu={}); \
370                 GPU-only encode policy has no CPU fallback",
371                dev.name,
372            ));
373        }
374        return Err(anyhow::anyhow!(
375            "vendor-pinned encoder requested (vendor={pinned:?}, gpu_index={:?}) but no matching GPU found",
376            config.gpu_index,
377        ));
378    }
379
380    // Auto-select: NVIDIA NVENC (Ada+) first, then AMD AMF (RDNA3+),
381    // then Intel QSV (Arc / Meteor Lake+). No CPU fallback; hosts
382    // without any AV1 encode silicon hard-fail at the end of the chain.
383    //
384    // Per-vendor device resolution: when `config.gpu_index` is Some,
385    // prefer the GPU with matching `.index` for that vendor so
386    // multi-GPU hosts can pin variant N → device N. When None, fall
387    // back to first-of-vendor (single-GPU behaviour preserved).
388    if let Some(dev) = pick_vendor_device(&gpus, gpu::GpuVendor::Nvidia, config.gpu_index) {
389        if gpu::supports_av1_encode(dev) {
390            match nvenc::NvencEncoder::new(config.clone(), dev.index) {
391                Ok(enc) => {
392                    tracing::info!(
393                        gpu_name = %dev.name,
394                        gpu_index = dev.index,
395                        "using NVENC AV1 hardware encoder"
396                    );
397                    return Ok(Box::new(enc));
398                }
399                Err(e) => {
400                    tracing::warn!(error = %e, "NVENC init failed, falling back to next backend");
401                }
402            }
403        } else {
404            // Capability gap, not an error: this NVIDIA GPU's NVENC silicon
405            // predates AV1 encode (AV1 NVENC was added in Ada Lovelace
406            // RTX 4000 and Ampere datacenter A10/A10G/L4/L40 — consumer
407            // 30-series and older do NOT have it). The GPU can still
408            // handle NVDEC decode; only the encode half falls through.
409            tracing::info!(
410                gpu = %dev.name,
411                "NVIDIA GPU lacks AV1 NVENC silicon — trying other GPU backends"
412            );
413        }
414    }
415
416    if let Some(dev) = pick_vendor_device(&gpus, gpu::GpuVendor::Amd, config.gpu_index) {
417        if gpu::supports_av1_encode(dev) {
418            match amf::AmfEncoder::new(config.clone(), dev.index) {
419                Ok(enc) => {
420                    tracing::info!(
421                        gpu_name = %dev.name,
422                        gpu_index = dev.index,
423                        "using AMF AV1 hardware encoder"
424                    );
425                    return Ok(Box::new(enc));
426                }
427                Err(e) => {
428                    tracing::warn!(error = %e, "AMF init failed, falling back to next backend");
429                }
430            }
431        } else {
432            tracing::info!(
433                gpu = %dev.name,
434                "AMD GPU predates RDNA3 — no AV1 AMF silicon; trying Intel / CPU"
435            );
436        }
437    }
438
439    if let Some(dev) = pick_vendor_device(&gpus, gpu::GpuVendor::Intel, config.gpu_index) {
440        if gpu::supports_av1_encode(dev) {
441            match make_qsv_encoder(config.clone(), dev.index) {
442                Ok(enc) => {
443                    tracing::info!(
444                        gpu_name = %dev.name,
445                        gpu_index = dev.index,
446                        "using QSV AV1 hardware encoder"
447                    );
448                    return Ok(enc);
449                }
450                Err(e) => {
451                    tracing::warn!(error = %e, "QSV init failed; chain exhausted");
452                }
453            }
454        } else {
455            tracing::info!(
456                gpu = %dev.name,
457                "Intel GPU predates Arc/Meteor Lake — no AV1 QSV silicon"
458            );
459        }
460    }
461
462    // GPU-only encode (2026-05-08): no CPU fallback. A host that
463    // reaches this point has no AV1 encode silicon (or every vendor
464    // path failed init) and must be reprovisioned.
465    Err(anyhow::anyhow!(
466        "no AV1 GPU encoder available — the host needs NVIDIA Ada+ / AMD RDNA3+ / Intel Arc \
467         for AV1 hardware encoding. CPU encoding (rav1e) was removed per the GPU-only directive."
468    ))
469}
470
471fn create_backend(
472    backend: EncoderBackend,
473    config: EncoderConfig,
474    gpus: &[gpu::GpuDevice],
475) -> Result<Box<dyn Encoder>> {
476    match backend {
477        EncoderBackend::Nvenc => {
478            let dev = pick_vendor_device(gpus, gpu::GpuVendor::Nvidia, config.gpu_index)
479                .ok_or_else(|| match config.gpu_index {
480                    Some(idx) => anyhow::anyhow!(
481                        "NVENC requested on GPU index {idx} but no NVIDIA GPU with that index found"
482                    ),
483                    None => anyhow::anyhow!("NVENC requested but no NVIDIA GPU found"),
484                })?;
485            Ok(Box::new(nvenc::NvencEncoder::new(config, dev.index)?))
486        }
487        EncoderBackend::Amf => {
488            let dev = pick_vendor_device(gpus, gpu::GpuVendor::Amd, config.gpu_index).ok_or_else(
489                || match config.gpu_index {
490                    Some(idx) => anyhow::anyhow!(
491                        "AMF requested on GPU index {idx} but no AMD GPU with that index found"
492                    ),
493                    None => anyhow::anyhow!("AMF requested but no AMD GPU found"),
494                },
495            )?;
496            Ok(Box::new(amf::AmfEncoder::new(config, dev.index)?))
497        }
498        EncoderBackend::Qsv => {
499            let dev = pick_vendor_device(gpus, gpu::GpuVendor::Intel, config.gpu_index)
500                .ok_or_else(|| match config.gpu_index {
501                    Some(idx) => anyhow::anyhow!(
502                        "QSV requested on GPU index {idx} but no Intel GPU with that index found"
503                    ),
504                    None => anyhow::anyhow!("QSV requested but no Intel GPU found"),
505                })?;
506            Ok(Box::new(qsv::QsvEncoder::new(config, dev.index)?))
507        }
508    }
509}
510
511#[cfg(test)]
512mod gpu_selection_tests {
513    use super::*;
514    use crate::gpu::{GpuDevice, GpuVendor};
515
516    fn synth(index: u32, vendor: GpuVendor) -> GpuDevice {
517        GpuDevice {
518            index,
519            vendor,
520            name: format!("synthetic-{index}"),
521            generation: String::new(),
522            pci_id: String::new(),
523            vram_mib: 0,
524            serial: None,
525            host_pci_address: String::new(),
526            vendor_id_hex: String::new(),
527        }
528    }
529
530    #[test]
531    fn pick_vendor_device_defaults_to_first_of_vendor_when_no_request() {
532        // requested=None → first matching vendor wins (pre-multi-GPU
533        // behaviour preserved).
534        let gpus = vec![
535            synth(0, GpuVendor::Nvidia),
536            synth(1, GpuVendor::Nvidia),
537            synth(2, GpuVendor::Amd),
538        ];
539        let nv = pick_vendor_device(&gpus, GpuVendor::Nvidia, None).unwrap();
540        assert_eq!(nv.index, 0);
541        let amd = pick_vendor_device(&gpus, GpuVendor::Amd, None).unwrap();
542        assert_eq!(amd.index, 2);
543    }
544
545    #[test]
546    fn pick_vendor_device_honours_explicit_request() {
547        // requested=Some(1) + vendor=Nvidia → must find GPU with
548        // index==1 AND vendor==Nvidia, not just first Nvidia.
549        let gpus = vec![
550            synth(0, GpuVendor::Nvidia),
551            synth(1, GpuVendor::Nvidia),
552            synth(2, GpuVendor::Nvidia),
553        ];
554        let dev = pick_vendor_device(&gpus, GpuVendor::Nvidia, Some(1)).unwrap();
555        assert_eq!(dev.index, 1);
556        let dev2 = pick_vendor_device(&gpus, GpuVendor::Nvidia, Some(2)).unwrap();
557        assert_eq!(dev2.index, 2);
558    }
559
560    #[test]
561    fn pick_vendor_device_returns_none_when_index_vendor_mismatch() {
562        // requested=Some(2) + vendor=Nvidia but GPU 2 is AMD → None.
563        // select_encoder then falls through to the AMD tier which will
564        // find GPU 2 on its own find() pass.
565        let gpus = vec![synth(0, GpuVendor::Nvidia), synth(2, GpuVendor::Amd)];
566        assert!(pick_vendor_device(&gpus, GpuVendor::Nvidia, Some(2)).is_none());
567        // Confirm the AMD tier finds it correctly with the same request.
568        let dev = pick_vendor_device(&gpus, GpuVendor::Amd, Some(2)).unwrap();
569        assert_eq!(dev.index, 2);
570    }
571
572    #[test]
573    fn pick_vendor_device_no_gpus_returns_none() {
574        let gpus: Vec<GpuDevice> = vec![];
575        assert!(pick_vendor_device(&gpus, GpuVendor::Nvidia, None).is_none());
576        assert!(pick_vendor_device(&gpus, GpuVendor::Nvidia, Some(0)).is_none());
577    }
578
579    #[test]
580    fn encoder_config_default_has_no_gpu_pin() {
581        // Default is None so existing callers using `EncoderConfig {
582        // ..default() }` literals get the pre-multi-GPU first-of-vendor
583        // behaviour unchanged.
584        let cfg = EncoderConfig::default();
585        assert_eq!(cfg.gpu_index, None);
586    }
587}