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
471/// Whether an AV1 encoder can actually be constructed for this device — the
472/// authoritative, build-aware capability check. It runs the **same**
473/// [`select_encoder`] dispatch a per-chunk worker uses, pinned to the device's
474/// vendor + index, so `true` means a worker leased to this GPU will encode
475/// rather than hard-fail. Used to drop AV1-incapable cards (e.g. a pre-Ada
476/// NVIDIA that decodes via NVDEC but has no AV1 encode silicon) from the
477/// multi-GPU encode pool, so a mixed-vendor host encodes on the capable cards
478/// instead of aborting when a chunk leases to an incapable one.
479///
480/// The probe constructs + immediately drops a real encoder, so the verdict is
481/// cached per GPU index (queried once per process).
482pub fn av1_encode_capable(dev: &gpu::GpuDevice) -> bool {
483    use std::collections::HashMap;
484    use std::sync::{Mutex, OnceLock};
485    static CACHE: OnceLock<Mutex<HashMap<u32, bool>>> = OnceLock::new();
486    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
487    if let Some(&cached) = cache.lock().unwrap().get(&dev.index) {
488        return cached;
489    }
490    // A representative, widely-accepted probe size; AV1 codec support does not
491    // depend on resolution, so any valid dims answer the capability question.
492    let probe = EncoderConfig {
493        width: 640,
494        height: 480,
495        frame_rate: 30.0,
496        gpu_index: Some(dev.index),
497        gpu_vendor: Some(dev.vendor),
498        ..Default::default()
499    };
500    let capable = match select_encoder(probe, None) {
501        Ok(_enc) => true, // encoder is dropped here, releasing the session
502        Err(e) => {
503            tracing::info!(
504                gpu_index = dev.index,
505                gpu = %dev.name,
506                vendor = ?dev.vendor,
507                error = %e,
508                "GPU cannot encode AV1 — excluding it from the encode pool (still usable for decode)"
509            );
510            false
511        }
512    };
513    cache.lock().unwrap().insert(dev.index, capable);
514    capable
515}
516
517fn create_backend(
518    backend: EncoderBackend,
519    config: EncoderConfig,
520    gpus: &[gpu::GpuDevice],
521) -> Result<Box<dyn Encoder>> {
522    match backend {
523        EncoderBackend::Nvenc => {
524            let dev = pick_vendor_device(gpus, gpu::GpuVendor::Nvidia, config.gpu_index)
525                .ok_or_else(|| match config.gpu_index {
526                    Some(idx) => anyhow::anyhow!(
527                        "NVENC requested on GPU index {idx} but no NVIDIA GPU with that index found"
528                    ),
529                    None => anyhow::anyhow!("NVENC requested but no NVIDIA GPU found"),
530                })?;
531            Ok(Box::new(nvenc::NvencEncoder::new(config, dev.index)?))
532        }
533        EncoderBackend::Amf => {
534            let dev = pick_vendor_device(gpus, gpu::GpuVendor::Amd, config.gpu_index).ok_or_else(
535                || match config.gpu_index {
536                    Some(idx) => anyhow::anyhow!(
537                        "AMF requested on GPU index {idx} but no AMD GPU with that index found"
538                    ),
539                    None => anyhow::anyhow!("AMF requested but no AMD GPU found"),
540                },
541            )?;
542            Ok(Box::new(amf::AmfEncoder::new(config, dev.index)?))
543        }
544        EncoderBackend::Qsv => {
545            let dev = pick_vendor_device(gpus, gpu::GpuVendor::Intel, config.gpu_index)
546                .ok_or_else(|| match config.gpu_index {
547                    Some(idx) => anyhow::anyhow!(
548                        "QSV requested on GPU index {idx} but no Intel GPU with that index found"
549                    ),
550                    None => anyhow::anyhow!("QSV requested but no Intel GPU found"),
551                })?;
552            Ok(Box::new(qsv::QsvEncoder::new(config, dev.index)?))
553        }
554    }
555}
556
557#[cfg(test)]
558mod gpu_selection_tests {
559    use super::*;
560    use crate::gpu::{GpuDevice, GpuVendor};
561
562    fn synth(index: u32, vendor: GpuVendor) -> GpuDevice {
563        GpuDevice {
564            index,
565            vendor,
566            name: format!("synthetic-{index}"),
567            generation: String::new(),
568            pci_id: String::new(),
569            vram_mib: 0,
570            serial: None,
571            host_pci_address: String::new(),
572            vendor_id_hex: String::new(),
573        }
574    }
575
576    #[test]
577    fn pick_vendor_device_defaults_to_first_of_vendor_when_no_request() {
578        // requested=None → first matching vendor wins (pre-multi-GPU
579        // behaviour preserved).
580        let gpus = vec![
581            synth(0, GpuVendor::Nvidia),
582            synth(1, GpuVendor::Nvidia),
583            synth(2, GpuVendor::Amd),
584        ];
585        let nv = pick_vendor_device(&gpus, GpuVendor::Nvidia, None).unwrap();
586        assert_eq!(nv.index, 0);
587        let amd = pick_vendor_device(&gpus, GpuVendor::Amd, None).unwrap();
588        assert_eq!(amd.index, 2);
589    }
590
591    #[test]
592    fn pick_vendor_device_honours_explicit_request() {
593        // requested=Some(1) + vendor=Nvidia → must find GPU with
594        // index==1 AND vendor==Nvidia, not just first Nvidia.
595        let gpus = vec![
596            synth(0, GpuVendor::Nvidia),
597            synth(1, GpuVendor::Nvidia),
598            synth(2, GpuVendor::Nvidia),
599        ];
600        let dev = pick_vendor_device(&gpus, GpuVendor::Nvidia, Some(1)).unwrap();
601        assert_eq!(dev.index, 1);
602        let dev2 = pick_vendor_device(&gpus, GpuVendor::Nvidia, Some(2)).unwrap();
603        assert_eq!(dev2.index, 2);
604    }
605
606    #[test]
607    fn pick_vendor_device_returns_none_when_index_vendor_mismatch() {
608        // requested=Some(2) + vendor=Nvidia but GPU 2 is AMD → None.
609        // select_encoder then falls through to the AMD tier which will
610        // find GPU 2 on its own find() pass.
611        let gpus = vec![synth(0, GpuVendor::Nvidia), synth(2, GpuVendor::Amd)];
612        assert!(pick_vendor_device(&gpus, GpuVendor::Nvidia, Some(2)).is_none());
613        // Confirm the AMD tier finds it correctly with the same request.
614        let dev = pick_vendor_device(&gpus, GpuVendor::Amd, Some(2)).unwrap();
615        assert_eq!(dev.index, 2);
616    }
617
618    #[test]
619    fn pick_vendor_device_no_gpus_returns_none() {
620        let gpus: Vec<GpuDevice> = vec![];
621        assert!(pick_vendor_device(&gpus, GpuVendor::Nvidia, None).is_none());
622        assert!(pick_vendor_device(&gpus, GpuVendor::Nvidia, Some(0)).is_none());
623    }
624
625    #[test]
626    fn encoder_config_default_has_no_gpu_pin() {
627        // Default is None so existing callers using `EncoderConfig {
628        // ..default() }` literals get the pre-multi-GPU first-of-vendor
629        // behaviour unchanged.
630        let cfg = EncoderConfig::default();
631        assert_eq!(cfg.gpu_index, None);
632    }
633}