use anyhow::{bail, Result};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct GpuInfo {
pub name: String,
pub encoder: String,
pub kind: GpuKind,
pub supports_10bit_hevc: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum GpuKind {
Nvidia,
Intel,
Apple,
}
const NVENC_NO_10BIT_HEVC: &[&str] = &[
"GTX 750",
"GTX 9", "GTX 1050", "GTX 1060", "GTX 1070", "GTX 1080",
];
fn nvidia_supports_10bit_hevc(name: &str) -> bool {
let lower = name.to_lowercase();
for fragment in NVENC_NO_10BIT_HEVC {
if lower.contains(&fragment.to_lowercase()) {
return false;
}
}
true
}
pub fn detect_gpu() -> Result<GpuInfo> {
if let Ok(nvidia) = detect_nvidia() {
if has_ffmpeg_encoder("hevc_nvenc") {
let supports_10bit = nvidia_supports_10bit_hevc(&nvidia);
return Ok(GpuInfo {
name: nvidia,
encoder: "hevc_nvenc".to_string(),
kind: GpuKind::Nvidia,
supports_10bit_hevc: supports_10bit,
});
}
log::debug!("NVIDIA GPU found but hevc_nvenc not available in ffmpeg");
}
if detect_intel_gpu() {
if has_ffmpeg_encoder("hevc_vaapi") {
return Ok(GpuInfo {
name: "Intel GPU (VAAPI)".to_string(),
encoder: "hevc_vaapi".to_string(),
kind: GpuKind::Intel,
supports_10bit_hevc: true,
});
}
log::debug!("Intel GPU found but hevc_vaapi not available in ffmpeg");
}
if detect_apple_gpu() {
if has_ffmpeg_encoder("hevc_videotoolbox") {
return Ok(GpuInfo {
name: detect_apple_chip_name(),
encoder: "hevc_videotoolbox".to_string(),
kind: GpuKind::Apple,
supports_10bit_hevc: true,
});
}
log::debug!("Apple GPU found but hevc_videotoolbox not available in ffmpeg");
}
bail!("{}", no_gpu_message())
}
fn no_gpu_message() -> String {
let mut msg = String::from(
"No GPU found for h265 encoding!\n\
hvac requires one of:\n\
- NVIDIA GPU with NVENC support (hevc_nvenc)\n\
- Intel GPU with VAAPI support (hevc_vaapi)\n\
- Apple Silicon or Mac with VideoToolbox (hevc_videotoolbox)\n\n",
);
if cfg!(target_os = "macos") {
msg.push_str(
"On macOS, VideoToolbox ships with the OS. This message usually means:\n\
- ffmpeg is not installed: brew install ffmpeg\n\
- or a non-default ffmpeg build lacks hevc_videotoolbox; reinstall from Homebrew\n",
);
} else if running_in_container() {
msg.push_str(
"Detected a container environment. The GPU device wasn't passed through:\n\
- Intel iGPU: add `--device /dev/dri:/dev/dri` to your docker run\n\
- NVIDIA: add `--gpus all --runtime=nvidia` (needs nvidia-container-toolkit)\n\
See https://github.com/JackDanger/hvac/blob/main/docs/NAS.md for the NAS-specific recipes.\n",
);
} else {
msg.push_str(
"Check that:\n\
1. A supported GPU is installed\n\
2. Drivers are loaded — `nvidia-smi` for NVIDIA, `ls /dev/dri && vainfo` for Intel\n\
3. ffmpeg is built with the appropriate encoder (`ffmpeg -encoders | grep hevc_`)\n\n\
Synology, QNAP, Unraid, OMV, TrueNAS: see\n\
https://github.com/JackDanger/hvac/blob/main/docs/NAS.md\n",
);
}
msg
}
fn running_in_container() -> bool {
if std::path::Path::new("/.dockerenv").exists()
|| std::path::Path::new("/run/.containerenv").exists()
{
return true;
}
std::env::var_os("KUBERNETES_SERVICE_HOST").is_some()
}
pub fn max_encode_sessions(gpu: &GpuInfo) -> usize {
match gpu.kind {
GpuKind::Nvidia => {
let name_lower = gpu.name.to_lowercase();
if name_lower.contains("quadro")
|| name_lower.contains("tesla")
|| name_lower.starts_with("a10")
|| name_lower.starts_with("a30")
|| name_lower.starts_with("a40")
|| name_lower.starts_with("a100")
|| name_lower.starts_with("l4")
|| name_lower.starts_with("l40")
|| name_lower.starts_with("h100")
{
4 } else {
3 }
}
GpuKind::Intel => 2,
GpuKind::Apple => 2,
}
}
fn detect_nvidia() -> Result<String> {
let output = Command::new("nvidia-smi")
.arg("--query-gpu=name")
.arg("--format=csv,noheader")
.output()?;
if !output.status.success() {
bail!("nvidia-smi failed");
}
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() {
bail!("no nvidia gpu found");
}
Ok(name)
}
fn detect_intel_gpu() -> bool {
std::path::Path::new("/dev/dri/renderD128").exists()
}
fn detect_apple_gpu() -> bool {
cfg!(target_os = "macos")
}
fn detect_apple_chip_name() -> String {
let output = Command::new("sysctl")
.arg("-n")
.arg("machdep.cpu.brand_string")
.output();
match output {
Ok(out) if out.status.success() => {
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !name.is_empty() {
return format!("Apple ({})", name);
}
}
_ => {}
}
"Apple GPU (VideoToolbox)".to_string()
}
fn has_ffmpeg_encoder(encoder: &str) -> bool {
let output = Command::new("ffmpeg")
.args(["-hide_banner", "-encoders"])
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
stdout.contains(encoder)
}
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_has_ffmpeg_encoder() {
let has_any = has_ffmpeg_encoder("libx265")
|| has_ffmpeg_encoder("hevc_nvenc")
|| has_ffmpeg_encoder("hevc_vaapi")
|| has_ffmpeg_encoder("hevc_videotoolbox");
assert!(has_any, "ffmpeg should have at least one h265 encoder");
}
#[test]
fn test_max_encode_sessions_consumer_nvidia() {
let gpu = GpuInfo {
name: "NVIDIA GeForce RTX 2060".to_string(),
encoder: "hevc_nvenc".to_string(),
kind: GpuKind::Nvidia,
supports_10bit_hevc: true,
};
assert_eq!(max_encode_sessions(&gpu), 3);
}
#[test]
fn test_max_encode_sessions_professional_nvidia() {
let gpu = GpuInfo {
name: "A100-SXM4-80GB".to_string(),
encoder: "hevc_nvenc".to_string(),
kind: GpuKind::Nvidia,
supports_10bit_hevc: true,
};
assert_eq!(max_encode_sessions(&gpu), 4);
}
#[test]
fn test_10bit_unsupported_maxwell_first_gen() {
assert!(!nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 750"));
assert!(!nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 750 Ti"));
}
#[test]
fn test_10bit_unsupported_maxwell_second_gen() {
assert!(!nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 950"));
assert!(!nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 960"));
assert!(!nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 970"));
assert!(!nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 980"));
assert!(!nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 980 Ti"));
}
#[test]
fn test_10bit_unsupported_early_pascal() {
assert!(!nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 1050"));
assert!(!nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 1050 Ti"));
assert!(!nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 1060 3GB"));
assert!(!nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 1060 6GB"));
assert!(!nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 1070"));
assert!(!nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 1070 Ti"));
assert!(!nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 1080"));
assert!(!nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 1080 Ti"));
}
#[test]
fn test_10bit_supported_turing_and_newer() {
assert!(nvidia_supports_10bit_hevc("NVIDIA GeForce RTX 2060"));
assert!(nvidia_supports_10bit_hevc("NVIDIA GeForce RTX 2080 Ti"));
assert!(nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 1660 Ti"));
assert!(nvidia_supports_10bit_hevc("NVIDIA GeForce GTX 1650"));
assert!(nvidia_supports_10bit_hevc("NVIDIA GeForce RTX 3060"));
assert!(nvidia_supports_10bit_hevc("NVIDIA GeForce RTX 3090"));
assert!(nvidia_supports_10bit_hevc("NVIDIA GeForce RTX 4090"));
assert!(nvidia_supports_10bit_hevc("NVIDIA A100-SXM4-80GB"));
assert!(nvidia_supports_10bit_hevc("Quadro RTX 5000"));
assert!(nvidia_supports_10bit_hevc("Tesla T4"));
}
#[test]
fn test_10bit_case_insensitive() {
assert!(!nvidia_supports_10bit_hevc("nvidia geforce gtx 970"));
assert!(!nvidia_supports_10bit_hevc("GEFORCE GTX 1080"));
}
#[test]
fn no_gpu_message_mentions_all_three_encoders() {
let m = no_gpu_message();
assert!(m.contains("hevc_nvenc"), "missing nvenc mention: {}", m);
assert!(m.contains("hevc_vaapi"), "missing vaapi mention: {}", m);
assert!(
m.contains("hevc_videotoolbox"),
"missing videotoolbox mention: {}",
m
);
}
#[test]
fn no_gpu_message_has_platform_specific_tail() {
let m = no_gpu_message();
if cfg!(target_os = "macos") {
assert!(m.contains("brew install ffmpeg"));
assert!(m.contains("VideoToolbox"));
} else if running_in_container() {
assert!(m.contains("--device /dev/dri") || m.contains("--gpus all"));
} else {
assert!(m.contains("nvidia-smi"));
assert!(m.contains("vainfo"));
assert!(m.contains("docs/NAS.md"));
}
}
#[test]
fn test_detect_gpu_returns_result() {
let result = detect_gpu();
match &result {
Ok(gpu) => {
assert!(!gpu.name.is_empty());
assert!(!gpu.encoder.is_empty());
println!("Detected GPU: {:?}", gpu);
}
Err(e) => {
println!("No GPU detected (expected in CI): {}", e);
}
}
}
}