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}