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, VideoCodec, 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 /// Output video codec. `Av1` (default, royalty-clean) or `H264` / `H265`
166 /// for legacy-player compatibility. The HW backends dispatch the codec
167 /// id / profile on this; the muxer picks the matching sample entry.
168 pub codec: VideoCodec,
169}
170
171/// Sentinel meaning "derive from `target` or `tier`".
172pub const AUTO_FROM_TARGET: u8 = u8::MAX;
173
174impl Default for EncoderConfig {
175 fn default() -> Self {
176 Self {
177 width: 1920,
178 height: 1080,
179 frame_rate: 30.0,
180 quality: AUTO_FROM_TARGET,
181 speed_preset: AUTO_FROM_TARGET,
182 keyframe_interval: 240,
183 target: QualityTarget::Standard,
184 tier: SpeedTier::Standard,
185 threads: 0,
186 // 8-bit SDR baseline — keeps every existing
187 // `EncoderConfig { ..default() }` literal compiling and
188 // behaving unchanged. 10-bit callers (Squad-19 rav1e or
189 // Squad-22 HW backends) explicitly opt in by setting
190 // `pixel_format = Yuv420p10le` and populating
191 // `color_metadata` from the source.
192 pixel_format: PixelFormat::Yuv420p,
193 color_metadata: ColorMetadata::default(),
194 gpu_index: None,
195 gpu_vendor: None,
196 constant_qp: false,
197 codec: VideoCodec::Av1,
198 }
199 }
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum EncoderBackend {
204 Nvenc,
205 Amf,
206 Qsv,
207}
208
209/// What output formats an encoder path can produce. AV1 here is 4:2:0 only;
210/// 10-bit output is the web-safe AV1 Main profile (4:2:0 10-bit), HDR-tagged at
211/// the container level (`colr`/`mdcv`/`clli`), not the wide-gamut professional
212/// profiles.
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub struct OutputCaps {
215 /// Highest luma bit depth the path can encode (8 or 10).
216 pub max_bit_depth: u8,
217 /// Can produce HDR (PQ/HLG + BT.2020) output — i.e. 10-bit AV1 + the muxer's
218 /// HDR color atoms.
219 pub hdr: bool,
220}
221
222/// Output capabilities of a specific hardware backend. All three do 10-bit AV1,
223/// so they can produce HDR without the `ffmpeg` feature: NVENC via
224/// `Yuv420_10bit`, AMF via `P010`, and QSV via the in-repo oneVPL P010 path
225/// ([`qsv_p010`]).
226pub fn backend_output_caps(backend: EncoderBackend) -> OutputCaps {
227 match backend {
228 EncoderBackend::Nvenc | EncoderBackend::Amf | EncoderBackend::Qsv => {
229 OutputCaps { max_bit_depth: 10, hdr: true }
230 }
231 }
232}
233
234/// Output capabilities of **this build** — the union over every compiled
235/// encoder path. 10-bit + HDR comes from NVENC (`nvidia`), AMF (`amd`), QSV
236/// (`qsv`, via the in-repo P010 path), or the `ffmpeg` software/hwaccel
237/// encoders; a build with no encoder feature is 8-bit. Callers (e.g. rivet's
238/// `OutputSpec::validate`) use this to reject a format the build can't produce.
239pub fn build_output_caps() -> OutputCaps {
240 #[cfg(any(
241 feature = "ffmpeg",
242 feature = "nvidia",
243 feature = "amd",
244 feature = "qsv"
245 ))]
246 {
247 return OutputCaps { max_bit_depth: 10, hdr: true };
248 }
249 #[allow(unreachable_code)]
250 OutputCaps { max_bit_depth: 8, hdr: false }
251}
252
253/// AV1-encode backends compiled into this build, in dispatch-preference order.
254pub fn encode_backends() -> Vec<&'static str> {
255 let mut v = Vec::new();
256 if cfg!(feature = "nvidia") {
257 v.push("nvenc");
258 }
259 if cfg!(feature = "amd") {
260 v.push("amf");
261 }
262 if cfg!(feature = "qsv") {
263 v.push("qsv");
264 }
265 if cfg!(feature = "ffmpeg") {
266 v.push("ffmpeg");
267 }
268 v
269}
270
271/// Construct the QSV encoder. The hand-rolled oneVPL encoder (`qsv.rs`) handles
272/// both 8-bit (NV12) and 10-bit (P010) AV1; under `not(qsv)` this hits the stub.
273fn make_qsv_encoder(config: EncoderConfig, gpu_index: u32) -> Result<Box<dyn Encoder>> {
274 Ok(Box::new(qsv::QsvEncoder::new(config, gpu_index)?))
275}
276
277/// Create the best available AV1 encoder.
278///
279/// Priority: NVENC (Ada+) → AMF (RDNA3+) → QSV (Arc / Meteor Lake+).
280///
281/// GPU-only — there is no CPU fallback. Hosts without AV1-encode
282/// silicon hard-fail at construction. The previous rav1e CPU and
283/// Vulkan Video tiers were removed 2026-05-08: rav1e on Archive
284/// preset doesn't keep up with real-time throughput at 4K and the
285/// Vulkan-encode binding never made it past scaffolding.
286/// All backends compiled in; availability checked at runtime.
287pub fn select_encoder(
288 config: EncoderConfig,
289 preferred: Option<EncoderBackend>,
290) -> Result<Box<dyn Encoder>> {
291 let gpus = gpu::detect_gpus();
292
293 if let Some(backend) = preferred {
294 return create_backend(backend, config, &gpus);
295 }
296
297 // Tier 0 (feature-gated): FFmpeg AV1 encoder (libavcodec's
298 // av1_nvenc / av1_amf / av1_qsv / av1_vaapi / libsvtav1 /
299 // libaom-av1 / librav1e probe chain). When the `ffmpeg` feature
300 // is built and DISABLE_FFMPEG is not set, FFmpeg is the first
301 // encoder tried for every host — one interface covers every GPU
302 // vendor AND the CPU fallbacks. The native NVENC / AMF / QSV /
303 // Vulkan AV1 / rav1e paths below remain as failover when the
304 // FFmpeg probe chain errors. See `docs/hw-matrix.md`.
305 #[cfg(feature = "ffmpeg")]
306 {
307 if !ffmpeg_disable_flag() {
308 match ffmpeg_enc::FfmpegEncoder::new(config.clone()) {
309 Ok(enc) => {
310 tracing::info!(
311 backend = "ffmpeg",
312 av1_encoder = enc.engaged(),
313 "FFmpeg primary encoder dispatch engaged"
314 );
315 return Ok(Box::new(enc));
316 }
317 Err(e) => {
318 tracing::warn!(
319 error = %e,
320 "FFmpeg AV1 encoder chain exhausted; falling through to native backends"
321 );
322 }
323 }
324 } else {
325 tracing::debug!("DISABLE_FFMPEG set; skipping FFmpeg encoder dispatch");
326 }
327 }
328
329 // Vendor-pin shortcut: when the caller has already chosen which
330 // GPU to use (CMAF orchestrator does this via the GpuPool lease,
331 // 2026-05-03), dispatch DIRECTLY to that vendor's backend
332 // instead of running the NVIDIA-first preference chain.
333 // Without this, a host with both NVIDIA + Intel GPUs always
334 // routed every variant to NVENC because the chain hits
335 // `pick_vendor_device(Nvidia, ...)` first; the Arc sat idle even
336 // when NVENC sessions were saturated. CPU rav1e remains the
337 // last-resort if hardware init fails on the pinned vendor.
338 if let Some(pinned) = config.gpu_vendor {
339 if let Some(dev) = pick_vendor_device(&gpus, pinned, config.gpu_index) {
340 if gpu::supports_av1_encode(dev) {
341 let attempt = match pinned {
342 gpu::GpuVendor::Nvidia => nvenc::NvencEncoder::new(config.clone(), dev.index)
343 .map(|e| Box::new(e) as Box<dyn Encoder>),
344 gpu::GpuVendor::Amd => amf::AmfEncoder::new(config.clone(), dev.index)
345 .map(|e| Box::new(e) as Box<dyn Encoder>),
346 gpu::GpuVendor::Intel => make_qsv_encoder(config.clone(), dev.index),
347 };
348 return match attempt {
349 Ok(enc) => {
350 tracing::info!(
351 gpu_name = %dev.name,
352 gpu_index = dev.index,
353 vendor = ?pinned,
354 codec = ?config.codec,
355 "using vendor-pinned hardware encoder (lease-driven dispatch)"
356 );
357 Ok(enc)
358 }
359 Err(e) => {
360 // GPU-only directive (2026-05-08): the caller
361 // pinned a vendor for a reason (lease-driven
362 // GPU pool dispatch). Init failure is a hard
363 // error — there is no CPU fallback. Surface
364 // the underlying driver error so the worker
365 // can report it on the failed-job event.
366 Err(anyhow::anyhow!(
367 "vendor-pinned {:?} encoder init failed (vendor={pinned:?}, gpu={}, idx={}): {e}",
368 config.codec,
369 dev.name,
370 dev.index,
371 ))
372 }
373 };
374 }
375 return Err(anyhow::anyhow!(
376 "vendor-pinned GPU lacks {:?} encode silicon (vendor={pinned:?}, gpu={}); \
377 GPU-only encode policy has no CPU fallback",
378 config.codec,
379 dev.name,
380 ));
381 }
382 return Err(anyhow::anyhow!(
383 "vendor-pinned encoder requested (vendor={pinned:?}, gpu_index={:?}) but no matching GPU found",
384 config.gpu_index,
385 ));
386 }
387
388 // Auto-select: NVIDIA NVENC (Ada+) first, then AMD AMF (RDNA3+),
389 // then Intel QSV (Arc / Meteor Lake+). No CPU fallback; hosts
390 // without any AV1 encode silicon hard-fail at the end of the chain.
391 //
392 // Per-vendor device resolution: when `config.gpu_index` is Some,
393 // prefer the GPU with matching `.index` for that vendor so
394 // multi-GPU hosts can pin variant N → device N. When None, fall
395 // back to first-of-vendor (single-GPU behaviour preserved).
396 if let Some(dev) = pick_vendor_device(&gpus, gpu::GpuVendor::Nvidia, config.gpu_index) {
397 if gpu::supports_av1_encode(dev) {
398 match nvenc::NvencEncoder::new(config.clone(), dev.index) {
399 Ok(enc) => {
400 tracing::info!(
401 gpu_name = %dev.name,
402 gpu_index = dev.index,
403 codec = ?config.codec,
404 "using NVENC hardware encoder"
405 );
406 return Ok(Box::new(enc));
407 }
408 Err(e) => {
409 tracing::warn!(error = %e, "NVENC init failed, falling back to next backend");
410 }
411 }
412 } else {
413 // Capability gap, not an error: this NVIDIA GPU's NVENC silicon
414 // predates AV1 encode (AV1 NVENC was added in Ada Lovelace
415 // RTX 4000 and Ampere datacenter A10/A10G/L4/L40 — consumer
416 // 30-series and older do NOT have it). The GPU can still
417 // handle NVDEC decode; only the encode half falls through.
418 tracing::info!(
419 gpu = %dev.name,
420 "NVIDIA GPU lacks AV1 NVENC silicon — trying other GPU backends"
421 );
422 }
423 }
424
425 if let Some(dev) = pick_vendor_device(&gpus, gpu::GpuVendor::Amd, config.gpu_index) {
426 if gpu::supports_av1_encode(dev) {
427 match amf::AmfEncoder::new(config.clone(), dev.index) {
428 Ok(enc) => {
429 tracing::info!(
430 gpu_name = %dev.name,
431 gpu_index = dev.index,
432 codec = ?config.codec,
433 "using AMF hardware encoder"
434 );
435 return Ok(Box::new(enc));
436 }
437 Err(e) => {
438 tracing::warn!(error = %e, "AMF init failed, falling back to next backend");
439 }
440 }
441 } else {
442 tracing::info!(
443 gpu = %dev.name,
444 "AMD GPU predates RDNA3 — no AV1 AMF silicon; trying Intel / CPU"
445 );
446 }
447 }
448
449 if let Some(dev) = pick_vendor_device(&gpus, gpu::GpuVendor::Intel, config.gpu_index) {
450 if gpu::supports_av1_encode(dev) {
451 match make_qsv_encoder(config.clone(), dev.index) {
452 Ok(enc) => {
453 tracing::info!(
454 gpu_name = %dev.name,
455 gpu_index = dev.index,
456 codec = ?config.codec,
457 "using QSV hardware encoder"
458 );
459 return Ok(enc);
460 }
461 Err(e) => {
462 tracing::warn!(error = %e, "QSV init failed; chain exhausted");
463 }
464 }
465 } else {
466 tracing::info!(
467 gpu = %dev.name,
468 "Intel GPU predates Arc/Meteor Lake — no AV1 QSV silicon"
469 );
470 }
471 }
472
473 // GPU-only encode (2026-05-08): no CPU fallback. A host that
474 // reaches this point has no AV1 encode silicon (or every vendor
475 // path failed init) and must be reprovisioned.
476 Err(anyhow::anyhow!(
477 "no AV1 GPU encoder available — the host needs NVIDIA Ada+ / AMD RDNA3+ / Intel Arc \
478 for AV1 hardware encoding. CPU encoding (rav1e) was removed per the GPU-only directive."
479 ))
480}
481
482/// Whether an AV1 encoder can actually be constructed for this device — the
483/// authoritative, build-aware capability check. It runs the **same**
484/// [`select_encoder`] dispatch a per-chunk worker uses, pinned to the device's
485/// vendor + index, so `true` means a worker leased to this GPU will encode
486/// rather than hard-fail. Used to drop AV1-incapable cards (e.g. a pre-Ada
487/// NVIDIA that decodes via NVDEC but has no AV1 encode silicon) from the
488/// multi-GPU encode pool, so a mixed-vendor host encodes on the capable cards
489/// instead of aborting when a chunk leases to an incapable one.
490///
491/// The probe constructs + immediately drops a real encoder, so the verdict is
492/// cached per GPU index (queried once per process).
493/// Whether `dev` can encode `codec` in hardware — probed by actually building
494/// the encoder the worker would use (vendor-pinned to this GPU) and seeing if
495/// init succeeds. Cached per `(gpu_index, codec)` since a GPU may encode H.264
496/// but not AV1 (e.g. NVIDIA Ampere consumer: H.264/H.265 yes, AV1 no). A GPU
497/// that fails is dropped from the *encode* pool for that codec but stays usable
498/// for decode.
499pub fn encode_capable(dev: &gpu::GpuDevice, codec: VideoCodec) -> bool {
500 use std::collections::HashMap;
501 use std::sync::{Mutex, OnceLock};
502 static CACHE: OnceLock<Mutex<HashMap<(u32, VideoCodec), bool>>> = OnceLock::new();
503 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
504 let key = (dev.index, codec);
505 if let Some(&cached) = cache.lock().unwrap().get(&key) {
506 return cached;
507 }
508 // A representative, widely-accepted probe size; codec support does not
509 // depend on resolution, so any valid dims answer the capability question.
510 let probe = EncoderConfig {
511 width: 640,
512 height: 480,
513 frame_rate: 30.0,
514 gpu_index: Some(dev.index),
515 gpu_vendor: Some(dev.vendor),
516 codec,
517 ..Default::default()
518 };
519 let capable = match select_encoder(probe, None) {
520 Ok(_enc) => true, // encoder is dropped here, releasing the session
521 Err(e) => {
522 tracing::info!(
523 gpu_index = dev.index,
524 gpu = %dev.name,
525 vendor = ?dev.vendor,
526 ?codec,
527 error = %e,
528 "GPU cannot encode this codec — excluding it from the encode pool (still usable for decode)"
529 );
530 false
531 }
532 };
533 cache.lock().unwrap().insert(key, capable);
534 capable
535}
536
537/// Back-compat shim: AV1 encode capability (the inventory's "AV1" column).
538pub fn av1_encode_capable(dev: &gpu::GpuDevice) -> bool {
539 encode_capable(dev, VideoCodec::Av1)
540}
541
542fn create_backend(
543 backend: EncoderBackend,
544 config: EncoderConfig,
545 gpus: &[gpu::GpuDevice],
546) -> Result<Box<dyn Encoder>> {
547 match backend {
548 EncoderBackend::Nvenc => {
549 let dev = pick_vendor_device(gpus, gpu::GpuVendor::Nvidia, config.gpu_index)
550 .ok_or_else(|| match config.gpu_index {
551 Some(idx) => anyhow::anyhow!(
552 "NVENC requested on GPU index {idx} but no NVIDIA GPU with that index found"
553 ),
554 None => anyhow::anyhow!("NVENC requested but no NVIDIA GPU found"),
555 })?;
556 Ok(Box::new(nvenc::NvencEncoder::new(config, dev.index)?))
557 }
558 EncoderBackend::Amf => {
559 let dev = pick_vendor_device(gpus, gpu::GpuVendor::Amd, config.gpu_index).ok_or_else(
560 || match config.gpu_index {
561 Some(idx) => anyhow::anyhow!(
562 "AMF requested on GPU index {idx} but no AMD GPU with that index found"
563 ),
564 None => anyhow::anyhow!("AMF requested but no AMD GPU found"),
565 },
566 )?;
567 Ok(Box::new(amf::AmfEncoder::new(config, dev.index)?))
568 }
569 EncoderBackend::Qsv => {
570 let dev = pick_vendor_device(gpus, gpu::GpuVendor::Intel, config.gpu_index)
571 .ok_or_else(|| match config.gpu_index {
572 Some(idx) => anyhow::anyhow!(
573 "QSV requested on GPU index {idx} but no Intel GPU with that index found"
574 ),
575 None => anyhow::anyhow!("QSV requested but no Intel GPU found"),
576 })?;
577 Ok(Box::new(qsv::QsvEncoder::new(config, dev.index)?))
578 }
579 }
580}
581
582#[cfg(test)]
583mod gpu_selection_tests {
584 use super::*;
585 use crate::gpu::{GpuDevice, GpuVendor};
586
587 fn synth(index: u32, vendor: GpuVendor) -> GpuDevice {
588 GpuDevice {
589 index,
590 vendor,
591 name: format!("synthetic-{index}"),
592 generation: String::new(),
593 pci_id: String::new(),
594 vram_mib: 0,
595 serial: None,
596 host_pci_address: String::new(),
597 vendor_id_hex: String::new(),
598 }
599 }
600
601 #[test]
602 fn pick_vendor_device_defaults_to_first_of_vendor_when_no_request() {
603 // requested=None → first matching vendor wins (pre-multi-GPU
604 // behaviour preserved).
605 let gpus = vec![
606 synth(0, GpuVendor::Nvidia),
607 synth(1, GpuVendor::Nvidia),
608 synth(2, GpuVendor::Amd),
609 ];
610 let nv = pick_vendor_device(&gpus, GpuVendor::Nvidia, None).unwrap();
611 assert_eq!(nv.index, 0);
612 let amd = pick_vendor_device(&gpus, GpuVendor::Amd, None).unwrap();
613 assert_eq!(amd.index, 2);
614 }
615
616 #[test]
617 fn pick_vendor_device_honours_explicit_request() {
618 // requested=Some(1) + vendor=Nvidia → must find GPU with
619 // index==1 AND vendor==Nvidia, not just first Nvidia.
620 let gpus = vec![
621 synth(0, GpuVendor::Nvidia),
622 synth(1, GpuVendor::Nvidia),
623 synth(2, GpuVendor::Nvidia),
624 ];
625 let dev = pick_vendor_device(&gpus, GpuVendor::Nvidia, Some(1)).unwrap();
626 assert_eq!(dev.index, 1);
627 let dev2 = pick_vendor_device(&gpus, GpuVendor::Nvidia, Some(2)).unwrap();
628 assert_eq!(dev2.index, 2);
629 }
630
631 #[test]
632 fn pick_vendor_device_returns_none_when_index_vendor_mismatch() {
633 // requested=Some(2) + vendor=Nvidia but GPU 2 is AMD → None.
634 // select_encoder then falls through to the AMD tier which will
635 // find GPU 2 on its own find() pass.
636 let gpus = vec![synth(0, GpuVendor::Nvidia), synth(2, GpuVendor::Amd)];
637 assert!(pick_vendor_device(&gpus, GpuVendor::Nvidia, Some(2)).is_none());
638 // Confirm the AMD tier finds it correctly with the same request.
639 let dev = pick_vendor_device(&gpus, GpuVendor::Amd, Some(2)).unwrap();
640 assert_eq!(dev.index, 2);
641 }
642
643 #[test]
644 fn pick_vendor_device_no_gpus_returns_none() {
645 let gpus: Vec<GpuDevice> = vec![];
646 assert!(pick_vendor_device(&gpus, GpuVendor::Nvidia, None).is_none());
647 assert!(pick_vendor_device(&gpus, GpuVendor::Nvidia, Some(0)).is_none());
648 }
649
650 #[test]
651 fn encoder_config_default_has_no_gpu_pin() {
652 // Default is None so existing callers using `EncoderConfig {
653 // ..default() }` literals get the pre-multi-GPU first-of-vendor
654 // behaviour unchanged.
655 let cfg = EncoderConfig::default();
656 assert_eq!(cfg.gpu_index, None);
657 }
658}