rivet-codec 0.1.3

GPU video decode/encode dispatch (NVDEC/NVENC, AMF, QSV) plus colorspace, tonemap, audio, and probe for the rivet transcoder. Imported as `codec`.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
#[cfg(feature = "amd")]
pub mod amf;
#[cfg(not(feature = "amd"))]
#[path = "amf_stub.rs"]
pub mod amf;
#[cfg(feature = "ffmpeg")]
pub mod ffmpeg_enc;
#[cfg(feature = "nvidia")]
pub mod nvenc;
#[cfg(not(feature = "nvidia"))]
#[path = "nvenc_stub.rs"]
pub mod nvenc;
#[cfg(feature = "qsv")]
pub mod qsv;
#[cfg(not(feature = "qsv"))]
#[path = "qsv_stub.rs"]
pub mod qsv;
pub mod tuning;
// rav1e CPU encoder + Vulkan video encoder were deleted 2026-05-08
// per the GPU-only encoding directive. Production hosts must have
// AV1 silicon (NVIDIA Ada+ / AMD RDNA3+ / Intel Arc); jobs that
// land on a host without one of those vendor-native paths now
// hard-fail at encoder construction.

use crate::frame::{ColorMetadata, PixelFormat, VideoFrame};
use crate::gpu;
use anyhow::Result;
use bytes::Bytes;

pub use tuning::{QualityTarget, SpeedTier};

/// Pick a GPU for a given vendor, honouring an explicit `gpu_index`
/// request when set. Returns `None` if no vendor GPU is present OR
/// the requested index belongs to a different vendor.
///
/// - `requested = Some(idx)`: look up the GPU with `GpuDevice.index == idx`.
///   If it exists AND matches `vendor`, return it. If it exists but is
///   a different vendor (e.g. caller pinned variant to NVIDIA slot 2
///   but we're evaluating the AMD fallback branch), return `None` so
///   dispatch falls through to the next tier — the other vendor tiers
///   will see this same `requested` index and match it there.
/// - `requested = None`: first-of-vendor (original pre-multi-GPU
///   behaviour, single-GPU hosts unaffected).
fn pick_vendor_device(
    gpus: &[gpu::GpuDevice],
    vendor: gpu::GpuVendor,
    requested: Option<u32>,
) -> Option<&gpu::GpuDevice> {
    match requested {
        Some(idx) => gpus.iter().find(|g| g.index == idx && g.vendor == vendor),
        None => gpus.iter().find(|g| g.vendor == vendor),
    }
}

/// Shared truthy-string parse for env flags — mirrors the decode-side
/// `env_flag_truthy` so `DISABLE_FFMPEG=1` / `true` / `yes` / `on`
/// all work identically across decode + encode dispatch.
#[cfg(feature = "ffmpeg")]
fn ffmpeg_disable_flag() -> bool {
    match std::env::var("DISABLE_FFMPEG") {
        Ok(v) => {
            let v = v.to_ascii_lowercase();
            matches!(v.as_str(), "1" | "true" | "yes" | "on" | "y" | "t")
        }
        Err(_) => false,
    }
}

pub trait Encoder: Send {
    fn send_frame(&mut self, frame: &VideoFrame) -> Result<()>;
    fn flush(&mut self) -> Result<()>;
    fn receive_packet(&mut self) -> Result<Option<EncodedPacket>>;
}

#[derive(Debug, Clone)]
pub struct EncodedPacket {
    pub data: Bytes,
    pub pts: u64,
    pub is_keyframe: bool,
}

/// Encoder configuration.
///
/// Prefer `target` + `tier` — `quality` and `speed_preset` are the
/// legacy per-encoder escape hatches and are kept so existing callers
/// compile. When `quality` is set to its sentinel (u8::MAX) the
/// adapter derives the quantizer from `target` instead. Same for
/// `speed_preset` (u8::MAX sentinel → derive from `tier`).
#[derive(Debug, Clone)]
pub struct EncoderConfig {
    pub width: u32,
    pub height: u32,
    pub frame_rate: f64,
    /// Legacy escape hatch. `u8::MAX` means "derive from `target`".
    /// Otherwise: rav1e → used as quantizer 0-255; NVENC → scaled to
    /// its CQ range.
    pub quality: u8,
    /// Legacy escape hatch. `u8::MAX` means "derive from `tier`".
    pub speed_preset: u8,
    pub keyframe_interval: u32,
    /// Perceptual quality target. Defaults to `Standard` (VMAF ~90).
    pub target: QualityTarget,
    /// Speed tier (Draft / Standard / Archive). Defaults to `Standard`.
    pub tier: SpeedTier,
    /// Thread budget for this encoder instance. `0` means "use all cores"
    /// (rav1e default). When the pipeline runs N variants in parallel it
    /// should set this to `num_cpus / N` to avoid oversubscribing rayon
    /// workers across concurrent rav1e encoders.
    pub threads: usize,
    /// Input pixel format. Drives the encoder's bit-depth dispatch
    /// (Squad-19 rav1e CPU + Squad-22 NVENC/AMF/QSV, roadmap #5).
    /// `Yuv420p` → 8-bit AV1 Profile 0; `Yuv420p10le` → 10-bit AV1
    /// Profile 0 (10-bit 4:2:0 is allowed in Profile 0 per AV1 §5.5.2
    /// — `seq_profile=0`, `seq_color_config` emits `high_bitdepth=1`,
    /// `twelve_bit=0`). HW backends pick the matching surface fourcc:
    /// NVENC `YUV420_10BIT`, AMF `P010`, QSV `P010` + `BitDepthLuma=10`.
    /// Set once at encoder construction; flipping mid-session requires
    /// reinitialising. The muxer's `pixi`-equivalent + AV1 sequence
    /// header in `av1C` carry the bit depth so HDR-capable browsers
    /// see 10-bit signaling.
    pub pixel_format: PixelFormat,
    /// Source color metadata. Encoders write
    /// `color_primaries` / `transfer_characteristics` /
    /// `matrix_coefficients` / `color_range` into the AV1 sequence
    /// header so HDR-capable players see the correct PQ/HLG transfer
    /// + BT.2020 primaries straight off the bitstream — not just the
    /// container `colr` atom (Squad-19 rav1e + Squad-22 HW; complements
    /// Squad-18's container-side colr nclx writer). Without bitstream
    /// signalling, players that prefer the OBU header over the box
    /// (e.g. Chromium video framework) would silently fall back to
    /// BT.709. Defaults to SDR BT.709.
    pub color_metadata: ColorMetadata,
    /// Explicit GPU device index for HW encoders on multi-GPU hosts.
    /// When `Some(idx)`, `select_encoder` binds NVENC / AMF / QSV /
    /// Vulkan AV1 / FFmpeg hwaccel encoders to the device with
    /// `GpuDevice.index == idx`. When `None` (default), the first
    /// GPU of each vendor is used — matches the original pre-multi-GPU
    /// behaviour.
    ///
    /// Pipeline `transcode::run` assigns `variant_idx % devices.len()`
    /// per variant so a multi-variant job on a multi-GPU host spreads
    /// work across devices, matching the Python original's
    /// `ThreadPoolExecutor(max_workers=device_count)` per-variant fan-out.
    pub gpu_index: Option<u32>,
    /// Explicit vendor pin for HW encoder dispatch. When `Some(v)`,
    /// `select_encoder` skips the NVIDIA → AMD → Intel preference
    /// chain and goes DIRECTLY to the encoder backend matching `v`
    /// (NVENC for Nvidia, AMF for Amd, QSV for Intel). Used by the
    /// CMAF orchestrator to honor the GpuPool's lease — when the
    /// pool hands out an Intel slot (because the NVIDIA card is
    /// already encoding), this field tells the factory to dispatch
    /// to QSV instead of falling back to NVENC and pinning every
    /// variant to the NVIDIA card.
    ///
    /// `None` (default) preserves the legacy NVIDIA-first chain so
    /// CPU-only paths + tests + non-pool callers behave unchanged.
    pub gpu_vendor: Option<gpu::GpuVendor>,
    /// Prefer **constant-QP** rate control over the bitrate/quality default.
    /// Set by the multi-GPU single-file path under `ChunkSeamMode::ParallelConstQp`
    /// so independently-encoded chunks have a flat quality across the stitched
    /// seams. On NVENC this selects `RateControlMode::ConstQp` (the wrapper then
    /// uses the preset's default QP — the `target` bitrate mapping is skipped).
    /// AMD/QSV already encode constant-quality, so this is a no-op for them.
    pub constant_qp: bool,
}

/// Sentinel meaning "derive from `target` or `tier`".
pub const AUTO_FROM_TARGET: u8 = u8::MAX;

impl Default for EncoderConfig {
    fn default() -> Self {
        Self {
            width: 1920,
            height: 1080,
            frame_rate: 30.0,
            quality: AUTO_FROM_TARGET,
            speed_preset: AUTO_FROM_TARGET,
            keyframe_interval: 240,
            target: QualityTarget::Standard,
            tier: SpeedTier::Standard,
            threads: 0,
            // 8-bit SDR baseline — keeps every existing
            // `EncoderConfig { ..default() }` literal compiling and
            // behaving unchanged. 10-bit callers (Squad-19 rav1e or
            // Squad-22 HW backends) explicitly opt in by setting
            // `pixel_format = Yuv420p10le` and populating
            // `color_metadata` from the source.
            pixel_format: PixelFormat::Yuv420p,
            color_metadata: ColorMetadata::default(),
            gpu_index: None,
            gpu_vendor: None,
            constant_qp: false,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EncoderBackend {
    Nvenc,
    Amf,
    Qsv,
}

/// What output formats an encoder path can produce. AV1 here is 4:2:0 only;
/// 10-bit output is the web-safe AV1 Main profile (4:2:0 10-bit), HDR-tagged at
/// the container level (`colr`/`mdcv`/`clli`), not the wide-gamut professional
/// profiles.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutputCaps {
    /// Highest luma bit depth the path can encode (8 or 10).
    pub max_bit_depth: u8,
    /// Can produce HDR (PQ/HLG + BT.2020) output — i.e. 10-bit AV1 + the muxer's
    /// HDR color atoms.
    pub hdr: bool,
}

/// Output capabilities of a specific hardware backend. All three do 10-bit AV1,
/// so they can produce HDR without the `ffmpeg` feature: NVENC via
/// `Yuv420_10bit`, AMF via `P010`, and QSV via the in-repo oneVPL P010 path
/// ([`qsv_p010`]).
pub fn backend_output_caps(backend: EncoderBackend) -> OutputCaps {
    match backend {
        EncoderBackend::Nvenc | EncoderBackend::Amf | EncoderBackend::Qsv => {
            OutputCaps { max_bit_depth: 10, hdr: true }
        }
    }
}

/// Output capabilities of **this build** — the union over every compiled
/// encoder path. 10-bit + HDR comes from NVENC (`nvidia`), AMF (`amd`), QSV
/// (`qsv`, via the in-repo P010 path), or the `ffmpeg` software/hwaccel
/// encoders; a build with no encoder feature is 8-bit. Callers (e.g. rivet's
/// `OutputSpec::validate`) use this to reject a format the build can't produce.
pub fn build_output_caps() -> OutputCaps {
    #[cfg(any(
        feature = "ffmpeg",
        feature = "nvidia",
        feature = "amd",
        feature = "qsv"
    ))]
    {
        return OutputCaps { max_bit_depth: 10, hdr: true };
    }
    #[allow(unreachable_code)]
    OutputCaps { max_bit_depth: 8, hdr: false }
}

/// AV1-encode backends compiled into this build, in dispatch-preference order.
pub fn encode_backends() -> Vec<&'static str> {
    let mut v = Vec::new();
    if cfg!(feature = "nvidia") {
        v.push("nvenc");
    }
    if cfg!(feature = "amd") {
        v.push("amf");
    }
    if cfg!(feature = "qsv") {
        v.push("qsv");
    }
    if cfg!(feature = "ffmpeg") {
        v.push("ffmpeg");
    }
    v
}

/// Construct the QSV encoder. The hand-rolled oneVPL encoder (`qsv.rs`) handles
/// both 8-bit (NV12) and 10-bit (P010) AV1; under `not(qsv)` this hits the stub.
fn make_qsv_encoder(config: EncoderConfig, gpu_index: u32) -> Result<Box<dyn Encoder>> {
    Ok(Box::new(qsv::QsvEncoder::new(config, gpu_index)?))
}

/// Create the best available AV1 encoder.
///
/// Priority: NVENC (Ada+) → AMF (RDNA3+) → QSV (Arc / Meteor Lake+).
///
/// GPU-only — there is no CPU fallback. Hosts without AV1-encode
/// silicon hard-fail at construction. The previous rav1e CPU and
/// Vulkan Video tiers were removed 2026-05-08: rav1e on Archive
/// preset doesn't keep up with real-time throughput at 4K and the
/// Vulkan-encode binding never made it past scaffolding.
/// All backends compiled in; availability checked at runtime.
pub fn select_encoder(
    config: EncoderConfig,
    preferred: Option<EncoderBackend>,
) -> Result<Box<dyn Encoder>> {
    let gpus = gpu::detect_gpus();

    if let Some(backend) = preferred {
        return create_backend(backend, config, &gpus);
    }

    // Tier 0 (feature-gated): FFmpeg AV1 encoder (libavcodec's
    // av1_nvenc / av1_amf / av1_qsv / av1_vaapi / libsvtav1 /
    // libaom-av1 / librav1e probe chain). When the `ffmpeg` feature
    // is built and DISABLE_FFMPEG is not set, FFmpeg is the first
    // encoder tried for every host — one interface covers every GPU
    // vendor AND the CPU fallbacks. The native NVENC / AMF / QSV /
    // Vulkan AV1 / rav1e paths below remain as failover when the
    // FFmpeg probe chain errors. See `docs/hw-matrix.md`.
    #[cfg(feature = "ffmpeg")]
    {
        if !ffmpeg_disable_flag() {
            match ffmpeg_enc::FfmpegEncoder::new(config.clone()) {
                Ok(enc) => {
                    tracing::info!(
                        backend = "ffmpeg",
                        av1_encoder = enc.engaged(),
                        "FFmpeg primary encoder dispatch engaged"
                    );
                    return Ok(Box::new(enc));
                }
                Err(e) => {
                    tracing::warn!(
                        error = %e,
                        "FFmpeg AV1 encoder chain exhausted; falling through to native backends"
                    );
                }
            }
        } else {
            tracing::debug!("DISABLE_FFMPEG set; skipping FFmpeg encoder dispatch");
        }
    }

    // Vendor-pin shortcut: when the caller has already chosen which
    // GPU to use (CMAF orchestrator does this via the GpuPool lease,
    // 2026-05-03), dispatch DIRECTLY to that vendor's backend
    // instead of running the NVIDIA-first preference chain.
    // Without this, a host with both NVIDIA + Intel GPUs always
    // routed every variant to NVENC because the chain hits
    // `pick_vendor_device(Nvidia, ...)` first; the Arc sat idle even
    // when NVENC sessions were saturated. CPU rav1e remains the
    // last-resort if hardware init fails on the pinned vendor.
    if let Some(pinned) = config.gpu_vendor {
        if let Some(dev) = pick_vendor_device(&gpus, pinned, config.gpu_index) {
            if gpu::supports_av1_encode(dev) {
                let attempt = match pinned {
                    gpu::GpuVendor::Nvidia => nvenc::NvencEncoder::new(config.clone(), dev.index)
                        .map(|e| Box::new(e) as Box<dyn Encoder>),
                    gpu::GpuVendor::Amd => amf::AmfEncoder::new(config.clone(), dev.index)
                        .map(|e| Box::new(e) as Box<dyn Encoder>),
                    gpu::GpuVendor::Intel => make_qsv_encoder(config.clone(), dev.index),
                };
                return match attempt {
                    Ok(enc) => {
                        tracing::info!(
                            gpu_name = %dev.name,
                            gpu_index = dev.index,
                            vendor = ?pinned,
                            "using vendor-pinned AV1 hardware encoder (lease-driven dispatch)"
                        );
                        Ok(enc)
                    }
                    Err(e) => {
                        // GPU-only directive (2026-05-08): the caller
                        // pinned a vendor for a reason (lease-driven
                        // GPU pool dispatch). Init failure is a hard
                        // error — there is no CPU fallback. Surface
                        // the underlying driver error so the worker
                        // can report it on the failed-job event.
                        Err(anyhow::anyhow!(
                            "vendor-pinned AV1 encoder init failed (vendor={pinned:?}, gpu={}, idx={}): {e}",
                            dev.name,
                            dev.index,
                        ))
                    }
                };
            }
            return Err(anyhow::anyhow!(
                "vendor-pinned GPU lacks AV1 encode silicon (vendor={pinned:?}, gpu={}); \
                 GPU-only encode policy has no CPU fallback",
                dev.name,
            ));
        }
        return Err(anyhow::anyhow!(
            "vendor-pinned encoder requested (vendor={pinned:?}, gpu_index={:?}) but no matching GPU found",
            config.gpu_index,
        ));
    }

    // Auto-select: NVIDIA NVENC (Ada+) first, then AMD AMF (RDNA3+),
    // then Intel QSV (Arc / Meteor Lake+). No CPU fallback; hosts
    // without any AV1 encode silicon hard-fail at the end of the chain.
    //
    // Per-vendor device resolution: when `config.gpu_index` is Some,
    // prefer the GPU with matching `.index` for that vendor so
    // multi-GPU hosts can pin variant N → device N. When None, fall
    // back to first-of-vendor (single-GPU behaviour preserved).
    if let Some(dev) = pick_vendor_device(&gpus, gpu::GpuVendor::Nvidia, config.gpu_index) {
        if gpu::supports_av1_encode(dev) {
            match nvenc::NvencEncoder::new(config.clone(), dev.index) {
                Ok(enc) => {
                    tracing::info!(
                        gpu_name = %dev.name,
                        gpu_index = dev.index,
                        "using NVENC AV1 hardware encoder"
                    );
                    return Ok(Box::new(enc));
                }
                Err(e) => {
                    tracing::warn!(error = %e, "NVENC init failed, falling back to next backend");
                }
            }
        } else {
            // Capability gap, not an error: this NVIDIA GPU's NVENC silicon
            // predates AV1 encode (AV1 NVENC was added in Ada Lovelace
            // RTX 4000 and Ampere datacenter A10/A10G/L4/L40 — consumer
            // 30-series and older do NOT have it). The GPU can still
            // handle NVDEC decode; only the encode half falls through.
            tracing::info!(
                gpu = %dev.name,
                "NVIDIA GPU lacks AV1 NVENC silicon — trying other GPU backends"
            );
        }
    }

    if let Some(dev) = pick_vendor_device(&gpus, gpu::GpuVendor::Amd, config.gpu_index) {
        if gpu::supports_av1_encode(dev) {
            match amf::AmfEncoder::new(config.clone(), dev.index) {
                Ok(enc) => {
                    tracing::info!(
                        gpu_name = %dev.name,
                        gpu_index = dev.index,
                        "using AMF AV1 hardware encoder"
                    );
                    return Ok(Box::new(enc));
                }
                Err(e) => {
                    tracing::warn!(error = %e, "AMF init failed, falling back to next backend");
                }
            }
        } else {
            tracing::info!(
                gpu = %dev.name,
                "AMD GPU predates RDNA3 — no AV1 AMF silicon; trying Intel / CPU"
            );
        }
    }

    if let Some(dev) = pick_vendor_device(&gpus, gpu::GpuVendor::Intel, config.gpu_index) {
        if gpu::supports_av1_encode(dev) {
            match make_qsv_encoder(config.clone(), dev.index) {
                Ok(enc) => {
                    tracing::info!(
                        gpu_name = %dev.name,
                        gpu_index = dev.index,
                        "using QSV AV1 hardware encoder"
                    );
                    return Ok(enc);
                }
                Err(e) => {
                    tracing::warn!(error = %e, "QSV init failed; chain exhausted");
                }
            }
        } else {
            tracing::info!(
                gpu = %dev.name,
                "Intel GPU predates Arc/Meteor Lake — no AV1 QSV silicon"
            );
        }
    }

    // GPU-only encode (2026-05-08): no CPU fallback. A host that
    // reaches this point has no AV1 encode silicon (or every vendor
    // path failed init) and must be reprovisioned.
    Err(anyhow::anyhow!(
        "no AV1 GPU encoder available — the host needs NVIDIA Ada+ / AMD RDNA3+ / Intel Arc \
         for AV1 hardware encoding. CPU encoding (rav1e) was removed per the GPU-only directive."
    ))
}

fn create_backend(
    backend: EncoderBackend,
    config: EncoderConfig,
    gpus: &[gpu::GpuDevice],
) -> Result<Box<dyn Encoder>> {
    match backend {
        EncoderBackend::Nvenc => {
            let dev = pick_vendor_device(gpus, gpu::GpuVendor::Nvidia, config.gpu_index)
                .ok_or_else(|| match config.gpu_index {
                    Some(idx) => anyhow::anyhow!(
                        "NVENC requested on GPU index {idx} but no NVIDIA GPU with that index found"
                    ),
                    None => anyhow::anyhow!("NVENC requested but no NVIDIA GPU found"),
                })?;
            Ok(Box::new(nvenc::NvencEncoder::new(config, dev.index)?))
        }
        EncoderBackend::Amf => {
            let dev = pick_vendor_device(gpus, gpu::GpuVendor::Amd, config.gpu_index).ok_or_else(
                || match config.gpu_index {
                    Some(idx) => anyhow::anyhow!(
                        "AMF requested on GPU index {idx} but no AMD GPU with that index found"
                    ),
                    None => anyhow::anyhow!("AMF requested but no AMD GPU found"),
                },
            )?;
            Ok(Box::new(amf::AmfEncoder::new(config, dev.index)?))
        }
        EncoderBackend::Qsv => {
            let dev = pick_vendor_device(gpus, gpu::GpuVendor::Intel, config.gpu_index)
                .ok_or_else(|| match config.gpu_index {
                    Some(idx) => anyhow::anyhow!(
                        "QSV requested on GPU index {idx} but no Intel GPU with that index found"
                    ),
                    None => anyhow::anyhow!("QSV requested but no Intel GPU found"),
                })?;
            Ok(Box::new(qsv::QsvEncoder::new(config, dev.index)?))
        }
    }
}

#[cfg(test)]
mod gpu_selection_tests {
    use super::*;
    use crate::gpu::{GpuDevice, GpuVendor};

    fn synth(index: u32, vendor: GpuVendor) -> GpuDevice {
        GpuDevice {
            index,
            vendor,
            name: format!("synthetic-{index}"),
            generation: String::new(),
            pci_id: String::new(),
            vram_mib: 0,
            serial: None,
            host_pci_address: String::new(),
            vendor_id_hex: String::new(),
        }
    }

    #[test]
    fn pick_vendor_device_defaults_to_first_of_vendor_when_no_request() {
        // requested=None → first matching vendor wins (pre-multi-GPU
        // behaviour preserved).
        let gpus = vec![
            synth(0, GpuVendor::Nvidia),
            synth(1, GpuVendor::Nvidia),
            synth(2, GpuVendor::Amd),
        ];
        let nv = pick_vendor_device(&gpus, GpuVendor::Nvidia, None).unwrap();
        assert_eq!(nv.index, 0);
        let amd = pick_vendor_device(&gpus, GpuVendor::Amd, None).unwrap();
        assert_eq!(amd.index, 2);
    }

    #[test]
    fn pick_vendor_device_honours_explicit_request() {
        // requested=Some(1) + vendor=Nvidia → must find GPU with
        // index==1 AND vendor==Nvidia, not just first Nvidia.
        let gpus = vec![
            synth(0, GpuVendor::Nvidia),
            synth(1, GpuVendor::Nvidia),
            synth(2, GpuVendor::Nvidia),
        ];
        let dev = pick_vendor_device(&gpus, GpuVendor::Nvidia, Some(1)).unwrap();
        assert_eq!(dev.index, 1);
        let dev2 = pick_vendor_device(&gpus, GpuVendor::Nvidia, Some(2)).unwrap();
        assert_eq!(dev2.index, 2);
    }

    #[test]
    fn pick_vendor_device_returns_none_when_index_vendor_mismatch() {
        // requested=Some(2) + vendor=Nvidia but GPU 2 is AMD → None.
        // select_encoder then falls through to the AMD tier which will
        // find GPU 2 on its own find() pass.
        let gpus = vec![synth(0, GpuVendor::Nvidia), synth(2, GpuVendor::Amd)];
        assert!(pick_vendor_device(&gpus, GpuVendor::Nvidia, Some(2)).is_none());
        // Confirm the AMD tier finds it correctly with the same request.
        let dev = pick_vendor_device(&gpus, GpuVendor::Amd, Some(2)).unwrap();
        assert_eq!(dev.index, 2);
    }

    #[test]
    fn pick_vendor_device_no_gpus_returns_none() {
        let gpus: Vec<GpuDevice> = vec![];
        assert!(pick_vendor_device(&gpus, GpuVendor::Nvidia, None).is_none());
        assert!(pick_vendor_device(&gpus, GpuVendor::Nvidia, Some(0)).is_none());
    }

    #[test]
    fn encoder_config_default_has_no_gpu_pin() {
        // Default is None so existing callers using `EncoderConfig {
        // ..default() }` literals get the pre-multi-GPU first-of-vendor
        // behaviour unchanged.
        let cfg = EncoderConfig::default();
        assert_eq!(cfg.gpu_index, None);
    }
}