diff --git a/worker/src/dependency_locator.rs b/worker/src/dependency_locator.rs index da639c8..1a6bd10 100644 --- a/worker/src/dependency_locator.rs +++ b/worker/src/dependency_locator.rs @@ -2,6 +2,8 @@ use std::env; use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::OnceLock; use anyhow::{bail, Context, Result}; @@ -197,6 +199,120 @@ impl DependencyLocator { bail!("vspipe not found in {:?}", vs_dir); } + /// Whether a usable OpenCL device is available for the GPU NNEDI3CL path. + /// + /// Headless/VM/remote-desktop environments often have no OpenCL device, so + /// NNEDI3CL fails at runtime ("Invalid Value"). We detect this by actually + /// running NNEDI3CL through vspipe once, and cache the result keyed by the + /// system **boot time** — GPU availability can change across boots (and we + /// don't want to pay the probe on every interactive preview), but re-probing + /// each boot keeps it current without a stale forever-cache. The + /// `VAPOURBOX_DISABLE_OPENCL=1` env var forces a negative result (escape + /// hatch for the remote-desktop-into-a-running-machine case, and for CI). + pub fn opencl_available(&self) -> bool { + static CACHE: OnceLock = OnceLock::new(); + *CACHE.get_or_init(|| self.compute_opencl_available()) + } + + fn compute_opencl_available(&self) -> bool { + if env::var("VAPOURBOX_DISABLE_OPENCL") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) + { + return false; + } + + let boot = Self::boot_token(); + let cache_file = self.platform_dir().join("opencl-probe.json"); + + // Reuse a cached result only if it was recorded during this boot. + if !boot.is_empty() { + if let Ok(txt) = std::fs::read_to_string(&cache_file) { + if let Ok(json) = serde_json::from_str::(&txt) { + let cached_boot = json.get("boot").and_then(|v| v.as_str()); + let cached = json.get("opencl").and_then(|v| v.as_bool()); + if cached_boot == Some(boot.as_str()) { + if let Some(b) = cached { + return b; + } + } + } + } + } + + let result = self.probe_opencl(); + let _ = std::fs::write( + &cache_file, + serde_json::json!({ "boot": boot, "opencl": result }).to_string(), + ); + result + } + + /// Run NNEDI3CL through vspipe on a tiny clip; success ⇒ OpenCL usable. + fn probe_opencl(&self) -> bool { + let vspipe = match self.vspipe_path() { + Ok(p) => p, + Err(_) => return false, + }; + let script = "import vapoursynth as vs\n\ + core = vs.core\n\ + clip = core.std.BlankClip(width=256, height=256, format=vs.YUV420P8, length=1)\n\ + clip = core.nnedi3cl.NNEDI3CL(clip, field=1)\n\ + clip.set_output()\n"; + let tmp = env::temp_dir().join(format!("vapourbox_opencl_probe_{}.vpy", std::process::id())); + if std::fs::write(&tmp, script).is_err() { + return false; + } + // `-e 0` processes frame 0 only, which forces NNEDI3CL to init OpenCL. + let out = Command::new(&vspipe) + .args(["-e", "0", tmp.to_string_lossy().as_ref(), "-"]) + .envs(self.build_environment()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output(); + let _ = std::fs::remove_file(&tmp); + matches!(out, Ok(o) if o.status.success()) + } + + /// A token that is stable within a boot and changes across reboots. Empty + /// if it can't be determined (then we don't reuse the disk cache). + fn boot_token() -> String { + #[cfg(target_os = "macos")] + { + if let Ok(out) = Command::new("sysctl").args(["-n", "kern.boottime"]).output() { + if out.status.success() { + return String::from_utf8_lossy(&out.stdout).trim().to_string(); + } + } + } + #[cfg(target_os = "linux")] + { + if let Ok(stat) = std::fs::read_to_string("/proc/stat") { + for line in stat.lines() { + if let Some(rest) = line.strip_prefix("btime ") { + return rest.trim().to_string(); + } + } + } + } + #[cfg(target_os = "windows")] + { + if let Ok(out) = Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + "(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToFileTimeUtc()", + ]) + .output() + { + if out.status.success() { + return String::from_utf8_lossy(&out.stdout).trim().to_string(); + } + } + } + String::new() + } + /// Get the path to ffmpeg executable. pub fn ffmpeg_path(&self) -> Result { let exe_name = if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" }; diff --git a/worker/src/main.rs b/worker/src/main.rs index b5ef081..f491976 100644 --- a/worker/src/main.rs +++ b/worker/src/main.rs @@ -303,7 +303,12 @@ fn run_worker( // Generate VapourSynth script reporter.send_log(models::LogLevel::Info, "Generating VapourSynth script..."); - let script_generator = ScriptGenerator::new()?; + // Detect OpenCL availability so QTGMC falls back to CPU NNEDI3 on machines + // without a usable OpenCL device (headless CI, VMs, remote desktop). + let opencl_available = dependency_locator::DependencyLocator::new() + .map(|d| d.opencl_available()) + .unwrap_or(true); + let script_generator = ScriptGenerator::new()?.with_opencl_available(opencl_available); let script_path = script_generator .generate(&job) .with_context(|| "Failed to generate VapourSynth script")?; diff --git a/worker/src/pipeline_executor.rs b/worker/src/pipeline_executor.rs index 1c5b8bb..71711b5 100644 --- a/worker/src/pipeline_executor.rs +++ b/worker/src/pipeline_executor.rs @@ -879,7 +879,8 @@ impl PipelineExecutor { }; // FPS as rational - let script_generator = ScriptGenerator::new()?; + let script_generator = + ScriptGenerator::new()?.with_opencl_available(self.deps.opencl_available()); let (fps_num, fps_den) = script_generator.frame_rate_to_rational(frame_rate); let preview_params = PreviewParams { diff --git a/worker/src/script_generator.rs b/worker/src/script_generator.rs index 5d3afdd..9e81f1d 100644 --- a/worker/src/script_generator.rs +++ b/worker/src/script_generator.rs @@ -17,6 +17,11 @@ use crate::models::{ pub struct ScriptGenerator { template: String, preview_template: String, + /// Whether a usable OpenCL device is available. When false, the GPU + /// NNEDI3CL path is never emitted (QTGMC falls back to CPU NNEDI3), so the + /// script runs on headless/VM/remote-desktop machines without a GPU. + /// Defaults to true; callers set it from `DependencyLocator::opencl_available()`. + opencl_available: bool, } /// Parameters for preview script generation. @@ -42,7 +47,14 @@ impl ScriptGenerator { pub fn new() -> Result { let template = Self::load_template()?; let preview_template = Self::load_preview_template()?; - Ok(Self { template, preview_template }) + Ok(Self { template, preview_template, opencl_available: true }) + } + + /// Set whether OpenCL is available (probe result from `DependencyLocator`). + /// When false, the GPU NNEDI3CL path is suppressed and QTGMC uses CPU NNEDI3. + pub fn with_opencl_available(mut self, available: bool) -> Self { + self.opencl_available = available; + self } /// Generate a .vpy script file for the given job. @@ -404,7 +416,7 @@ impl ScriptGenerator { // lack NEON optimisations and run pure scalar C. // Only auto-enable when EdiMode is compatible (unset or "nnedi3") // because our eedi3m plugin lacks EEDI3CL. - let opencl; + let desired_opencl; #[cfg(target_os = "macos")] { let edi_ok = match params.edi_mode.as_deref() { @@ -414,7 +426,7 @@ impl ScriptGenerator { // Treat Some(false) as unset — the Dart UI serializes null // as false, so only explicit Some(true) means user opted in. let user_set = params.opencl.unwrap_or(false); - opencl = if user_set { + desired_opencl = if user_set { Some(true) } else if edi_ok { Some(true) @@ -424,7 +436,25 @@ impl ScriptGenerator { } #[cfg(not(target_os = "macos"))] { - opencl = params.opencl; + desired_opencl = params.opencl; + } + // Gate auto-enabled OpenCL on detected availability: if we'd + // enable the GPU NNEDI3CL path but no usable OpenCL device + // exists (headless CI, VM, remote desktop), fall back to CPU + // NNEDI3 so the script doesn't fail with "NNEDI3CL: Invalid + // Value". A user who *explicitly* enabled OpenCL overrides the + // probe — their choice wins (it will error if no device, by + // design). The Dart UI serializes an unset toggle as false, so + // only Some(true) counts as an explicit opt-in. + let user_forced_opencl = params.opencl == Some(true); + let opencl = + gate_opencl(desired_opencl, self.opencl_available, user_forced_opencl); + if !user_forced_opencl && desired_opencl == Some(true) && opencl == Some(false) { + eprintln!( + "OpenCL auto-enabled but no usable device detected — \ + falling back to CPU NNEDI3 (set the OpenCL option to \ + force it)" + ); } script = process_optional_bool("OPENCL", opencl, script); script = process_optional_int("DEVICE", params.device, script); @@ -942,6 +972,24 @@ fn process_optional_double(name: &str, value: Option, mut script: String) - } /// Process an optional boolean parameter. +/// Resolve the final OpenCL setting. +/// +/// `desired` is what we'd otherwise use (macOS auto-enable or the user's choice). +/// When `user_forced` is true the user explicitly opted in, so we honor `desired` +/// as-is (their choice overrides the probe — it errors if no device, by design). +/// Otherwise, if OpenCL would be on (`Some(true)`) but no usable device is +/// detected, we downgrade to `Some(false)` (CPU NNEDI3). All other cases pass +/// through unchanged. +fn gate_opencl(desired: Option, opencl_available: bool, user_forced: bool) -> Option { + if user_forced { + return desired; + } + match desired { + Some(true) if !opencl_available => Some(false), + other => other, + } +} + fn process_optional_bool(name: &str, value: Option, mut script: String) -> String { let start_tag = format!("{{{{#{}}}}}", name); let end_tag = format!("{{{{/{}}}}}", name); @@ -1016,4 +1064,25 @@ mod tests { let result = process_optional_int("NUM", None, input.to_string()); assert_eq!(result, "prefixsuffix"); } + + #[test] + fn test_gate_opencl_disables_auto_when_unavailable() { + // Auto-enabled OpenCL with no device → CPU fallback. + assert_eq!(gate_opencl(Some(true), false, false), Some(false)); + } + + #[test] + fn test_gate_opencl_user_forced_overrides_probe() { + // User explicitly enabled OpenCL → honored even if the probe found nothing. + assert_eq!(gate_opencl(Some(true), false, true), Some(true)); + assert_eq!(gate_opencl(Some(true), true, true), Some(true)); + } + + #[test] + fn test_gate_opencl_passthrough() { + assert_eq!(gate_opencl(Some(true), true, false), Some(true)); + assert_eq!(gate_opencl(Some(false), false, false), Some(false)); + assert_eq!(gate_opencl(None, false, false), None); + assert_eq!(gate_opencl(None, true, false), None); + } }