diff --git a/docs/linux-video-backends.md b/docs/linux-video-backends.md index cdd0a905e..38852ba31 100644 --- a/docs/linux-video-backends.md +++ b/docs/linux-video-backends.md @@ -1,9 +1,15 @@ # Troubleshoot unavailable Linux video backends -The Qt stream view requires a hardware decoder. On Intel systems, VA-API decoding can +The Qt stream view prefers a hardware decoder. On Intel systems, VA-API decoding can work without Vulkan Video decoding support. The stream view still uses Vulkan for -presentation; OpenGL is not an embedded streaming backend. Enabling software decoding -in a standalone streamer does not enable it in the Qt stream view. +presentation; OpenGL is not an embedded streaming backend. + +Auto never switches to software decoding on its own, including after a failed +hardware start. When no hardware decoder is available, choose **Software (CPU)** in +Stream settings to decode with the bundled FFmpeg software decoder. That path accepts +8-bit 4:2:0 SDR only: HDR, 10-bit, and 4:4:4 are refused before CloudMatch instead of +being downgraded, and frames are copied through a bounded CPU NV12 upload. Expect +higher CPU load and lower frame rates than hardware decoding. ## Check the host driver first diff --git a/locales/en.json b/locales/en.json index 454fe40bc..b19f4724a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -3295,13 +3295,15 @@ "s_qt_about_tagline": "Your games, anywhere.", "s_qt_update_confirm": "OpenNOW will close to install the verified update. Continue?", "s_qt_backend_auto_description": "Auto uses DX11 hardware decoding. DX12 and Vulkan texture sharing are not supported by the Windows stream view yet. Applies to the next stream.", - "s_qt_backend_description": "Choose a supported native backend. Applies to the next stream.", + "s_qt_backend_description": "Choose a supported decoder. Auto never falls back to software. Applies to the next stream.", "s_qt_codec_unavailable": "Not supported by the detected native decoder", "s_qt_backend_auto": "Auto (recommended)", "s_qt_backend_auto_detail": "Use a supported native backend", "s_qt_backend_checking": "Checking hardware…", "s_qt_backend_unavailable": "Not supported by this stream view", "s_qt_backend_hardware": "Hardware decoding", + "s_qt_backend_software": "Software (CPU)", + "s_qt_backend_software_detail": "CPU decoding; 8-bit 4:2:0 SDR only", "s_qt_decoder_pending": "The native decoder is still being detected. Please try again in a moment.", "s_qt_resume_stop_timeout": "The previous native stream is still stopping. Retry after cleanup finishes, or restart OpenNOW.", "s_qt_resume_stop_failed": "Could not stop the previous native connection", diff --git a/native/opennow-core/src/streamer.rs b/native/opennow-core/src/streamer.rs index 4d34d7ae8..1cf0d88a2 100644 --- a/native/opennow-core/src/streamer.rs +++ b/native/opennow-core/src/streamer.rs @@ -335,22 +335,41 @@ impl StreamerService { "HDR requires an active HDR-capable window output. Disable HDR or select a supported display.", )); } - if hdr && settings["decoderPreference"].as_str() == Some("software") { + let requested_backend = requested_embedded_backend(settings); + let software_requested = matches!(requested_backend.as_str(), "software" | "ffmpeg"); + if hdr && software_requested { return Err(invalid( "HDR requires a 10-bit hardware decoder; software decoding is not supported", )); } - let requested_backend = settings["nativeVideoBackend"].as_str().unwrap_or("auto"); + let requested_color = settings["colorQuality"].as_str().unwrap_or("8bit_420"); + if software_requested && requested_color != "8bit_420" { + return Err(invalid( + "Software decoding presents 8-bit 4:2:0 SDR only. Select 8-bit 4:2:0 in Stream settings or a hardware backend.", + )); + } + let software_available = capabilities["videoBackends"] + .as_array() + .into_iter() + .flatten() + .any(|backend| { + matches!(backend["backend"].as_str(), Some("software" | "ffmpeg")) + && backend["available"].as_bool() == Some(true) + }); let mut selected = capabilities.clone(); let backends = selected["videoBackends"] .as_array_mut() .ok_or_else(|| invalid("Embedded streamer did not report video backends"))?; for backend in backends.iter_mut() { let name = backend["backend"].as_str().unwrap_or(""); - let matches = !matches!(name, "software" | "ffmpeg") - && (requested_backend == "auto" - || requested_backend == name - || (requested_backend == "nvdec" && name == "cuda")); + let matches = if software_requested { + matches!(name, "software" | "ffmpeg") + } else { + !matches!(name, "software" | "ffmpeg") + && (requested_backend == "auto" + || requested_backend == name + || (requested_backend == "nvdec" && name == "cuda")) + }; if !matches { backend["available"] = json!(false); } @@ -359,12 +378,14 @@ impl StreamerService { .iter() .any(|backend| backend["available"].as_bool() == Some(true)) { - let message = if requested_backend == "auto" { - "No hardware video backend is available for embedded streaming on this device. Check the hardware drivers and export diagnostics for backend probe failures.".to_owned() - } else { + let message = if software_requested { + format!( + "The FFmpeg software decoder is unavailable for the requested {requested_color} profile. Select Auto in Stream settings, or export diagnostics for backend probe failures." + ) + } else if requested_backend != "auto" { let mut message = format!( "The {} backend is unavailable for embedded streaming on this device. Select Auto in Stream settings.", - crate::diagnostics::runtime_failure_reason(requested_backend) + crate::diagnostics::runtime_failure_reason(&requested_backend) ); let evidence = crate::diagnostics::native_runtime_evidence(capabilities); if let Some(reason) = evidence["videoBackends"] @@ -381,6 +402,10 @@ impl StreamerService { message.push_str(&format!(" {reason}")); } message + } else if software_available { + "No hardware video backend is available for embedded streaming on this device. Select Software (CPU) to decode on the CPU, or export diagnostics for probe failures.".to_owned() + } else { + "No hardware video backend is available for embedded streaming on this device. Check the hardware drivers and export diagnostics for backend probe failures.".to_owned() }; return Err(StreamerError { code: "streamer_backend_unavailable", @@ -495,7 +520,7 @@ impl StreamerService { }; candidates.iter().find(|codec| codec_available(&selected, codec)).copied() .ok_or_else(|| StreamerError { code: "streamer_codec_unavailable", - message: "No available hardware codec supports the requested color mode. Try 8-bit 4:2:0 in Stream settings.".to_owned() })? + message: "No available codec supports the requested color mode. Try 8-bit 4:2:0 in Stream settings.".to_owned() })? } else { let codec = normalize_codec_name(&requested).ok_or_else(|| invalid("Unknown video codec"))?; @@ -1013,6 +1038,27 @@ fn probe_capabilities(executable: &Path, settings: &Value) -> Result String { + let requested = settings["nativeVideoBackend"] + .as_str() + .unwrap_or("auto") + .trim() + .to_ascii_lowercase(); + if !requested.is_empty() && requested != "auto" { + return requested; + } + match settings["decoderPreference"] + .as_str() + .unwrap_or("auto") + .trim() + .to_ascii_lowercase() + .as_str() + { + "software" => "software".to_owned(), + _ => "auto".to_owned(), + } +} + fn normalize_codec_name(value: &str) -> Option<&'static str> { match value.trim().to_ascii_lowercase().as_str() { "h264" | "avc" | "auto" | "" => Some("h264"), @@ -2352,6 +2398,71 @@ mod tests { assert!(!error.message.contains("Select Auto")); } + #[test] + fn embedded_software_resolution_honors_the_explicit_cpu_policy() { + let software_caps = json!({"protocolVersion":STREAMER_PROTOCOL_VERSION,"videoBackends":[ + {"backend":"vulkan", "platform":"linux", "available":true, "codecs":[ + {"codec":"h264", "available":true, "colorQualities":["8bit_420"]}, + {"codec":"h265", "available":true, "colorQualities":["8bit_420"]}]}, + {"backend":"ffmpeg", "platform":"linux", "available":true, "codecs":[ + {"codec":"h264", "available":true, "colorQualities":["8bit_420"]}]} + ]}); + for settings in [ + json!({"nativeVideoBackend":"software", "codec":"h264"}), + json!({"nativeVideoBackend":"ffmpeg", "codec":"h264"}), + json!({"decoderPreference":"software", "codec":"h264"}), + ] { + let resolved = + StreamerService::embedded_session_settings(&settings, &software_caps).unwrap(); + assert_eq!(resolved["codec"], "h264", "{settings}"); + } + let error = StreamerService::embedded_session_settings( + &json!({"nativeVideoBackend":"software", "codec":"h265"}), + &software_caps, + ) + .unwrap_err(); + assert_eq!(error.code, "streamer_codec_unavailable"); + assert!( + !error.message.contains("hardware"), + "software requests must not borrow hardware codecs: {}", + error.message + ); + let error = StreamerService::embedded_session_settings( + &json!({"nativeVideoBackend":"software", "colorQuality":"10bit_420"}), + &software_caps, + ) + .unwrap_err(); + assert!( + error + .message + .contains("Software decoding presents 8-bit 4:2:0 SDR only"), + "{}", + error.message + ); + let hardware_only = json!({"protocolVersion":STREAMER_PROTOCOL_VERSION,"videoBackends":[ + {"backend":"vulkan", "platform":"linux", "available":true, "codecs":[ + {"codec":"h264", "available":true, "colorQualities":["8bit_420"]}]} + ]}); + let error = StreamerService::embedded_session_settings( + &json!({"nativeVideoBackend":"software"}), + &hardware_only, + ) + .unwrap_err(); + assert!( + error + .message + .contains("FFmpeg software decoder is unavailable"), + "{}", + error.message + ); + let resolved = StreamerService::embedded_session_settings( + &json!({"nativeVideoBackend":"auto", "codec":"auto"}), + &software_caps, + ) + .unwrap(); + assert_eq!(resolved["codec"], "h265"); + } + #[test] fn embedded_macos_codec_policy_never_falls_back_to_unsupported_hardware() { let mut caps = json!({"protocolVersion":STREAMER_PROTOCOL_VERSION,"videoBackends":[{ diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-linux/tests/fixtures/software_decode_64x64.h264 b/native/opennow-streamer/crates/opennow-streamer-platform-linux/tests/fixtures/software_decode_64x64.h264 new file mode 100644 index 000000000..cdd5fc4e3 Binary files /dev/null and b/native/opennow-streamer/crates/opennow-streamer-platform-linux/tests/fixtures/software_decode_64x64.h264 differ diff --git a/native/opennow-streamer/crates/opennow-streamer-platform-linux/tests/software_decode.rs b/native/opennow-streamer/crates/opennow-streamer-platform-linux/tests/software_decode.rs new file mode 100644 index 000000000..ca34edcf5 --- /dev/null +++ b/native/opennow-streamer/crates/opennow-streamer-platform-linux/tests/software_decode.rs @@ -0,0 +1,124 @@ +#![cfg(all(target_os = "linux", feature = "ffmpeg"))] + +use opennow_streamer_platform_linux::{ + ColorTransfer, DecoderPreference, EncodedVideoFrame, LifecycleState, LinuxSession, PixelFormat, + PushOutcome, SessionConfig, StreamFormat, +}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +const BITSTREAM: &[u8] = include_bytes!("fixtures/software_decode_64x64.h264"); +const EXPECTED_FRAMES: usize = 30; +const SECOND_KEYFRAME: usize = 15; +const FRAME_INTERVAL_US: u64 = 33_333; + +fn access_units(stream: &[u8]) -> Vec<(Vec, bool)> { + let mut starts = Vec::new(); + for index in 0..stream.len().saturating_sub(3) { + if stream[index..index + 3] == [0, 0, 1] { + starts.push(index); + } + } + starts.push(stream.len()); + let mut units: Vec<(Vec, bool)> = Vec::new(); + let mut pending = Vec::new(); + let mut keyframe = false; + for window in starts.windows(2) { + let nal = stream[window[0] + 3] & 0x1f; + pending.extend_from_slice(&stream[window[0]..window[1]]); + keyframe |= matches!(nal, 5 | 7); + if matches!(nal, 1 | 5) { + units.push((std::mem::take(&mut pending), keyframe)); + keyframe = false; + } + } + units +} + +fn drain_until_silent( + session: &LinuxSession, + frames: &mut Vec, + silence: Duration, +) { + while let Some(frame) = session.recv_frame_timeout(silence) { + frames.push(frame); + } +} + +#[test] +fn software_only_decodes_cpu_nv12_sdr_frames_for_embedded_presentation() { + let mut config = SessionConfig::new(StreamFormat::h264_default(64, 64).unwrap()); + config.decoder_preference = DecoderPreference::SoftwareOnly; + config.embedded_presentation = true; + config.audio = None; + let mut session = + LinuxSession::start(config).expect("software decoder opens without a shared GPU device"); + let ready = Instant::now() + Duration::from_secs(3); + while session.state() != LifecycleState::Running { + assert!( + Instant::now() < ready, + "video worker did not reach Running: {:?}", + session.state() + ); + thread::sleep(Duration::from_millis(1)); + } + + let units = access_units(BITSTREAM); + assert_eq!(units.len(), EXPECTED_FRAMES); + let mut frames = Vec::new(); + let mut timestamp_us = 0; + for (data, keyframe) in units.iter() { + assert!(matches!( + session + .submit_video( + EncodedVideoFrame::new( + Arc::<[u8]>::from(data.as_slice()), + timestamp_us, + *keyframe + ) + .unwrap() + ) + .expect("access unit is admitted"), + PushOutcome::Queued | PushOutcome::DroppedOldest + )); + while let Some(frame) = session.try_recv_frame() { + frames.push(frame); + } + thread::sleep(Duration::from_millis(1)); + timestamp_us += FRAME_INTERVAL_US; + } + drain_until_silent(&session, &mut frames, Duration::from_millis(500)); + + assert!( + frames.len() > SECOND_KEYFRAME, + "decode must continue across the second keyframe, got {}", + frames.len() + ); + for (index, frame) in frames.iter().enumerate() { + assert_eq!( + frame.timestamp_us, + index as u64 * FRAME_INTERVAL_US, + "delivered frames must be a contiguous prefix" + ); + assert_eq!((frame.format.width, frame.format.height), (64, 64)); + assert_eq!(frame.format.pixel_format, PixelFormat::Nv12); + assert_eq!(frame.format.color_transfer, ColorTransfer::Sdr); + assert!(frame.vulkan.is_none() && frame.dmabuf.is_none()); + assert_eq!(frame.planes.len(), 2); + } + + let delivered = frames.len(); + session + .submit_video( + EncodedVideoFrame::new(Arc::<[u8]>::from(units[0].0.as_slice()), 1_000_000, true) + .unwrap(), + ) + .expect("fresh access unit is admitted"); + drain_until_silent(&session, &mut frames, Duration::from_millis(500)); + assert!( + frames.len() > delivered, + "new input must release look-ahead frames held by the decoder" + ); + session.stop().expect("session stops"); +} diff --git a/native/opennow-streamer/crates/opennow-streamer-platform/src/linux_backend.rs b/native/opennow-streamer/crates/opennow-streamer-platform/src/linux_backend.rs index 5b98bd84a..2b21989ff 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform/src/linux_backend.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform/src/linux_backend.rs @@ -173,6 +173,9 @@ fn select_embedded_fallback( stream: crate::MediaStreamConfig, backends: &[VideoBackendCapability], ) -> LinuxVideoSelection { + if matches!(requested, "software" | "ffmpeg") { + return select_embedded_software(stream, backends); + } if !stream.color_quality.is_444() { let color = if stream.color_quality.bit_depth() == 10 { "10bit_420" @@ -234,6 +237,47 @@ fn select_embedded_fallback( } } +fn select_embedded_software( + stream: crate::MediaStreamConfig, + backends: &[VideoBackendCapability], +) -> LinuxVideoSelection { + let software = backends.iter().find(|backend| { + backend.backend == "ffmpeg" + && backend.available + && backend.codecs.iter().any(|codec| { + codec.codec == stream.codec.label() + && codec.available + && codec + .color_qualities + .as_ref() + .is_none_or(|qualities| qualities.contains(&"8bit_420")) + }) + }); + let reason = if stream.hdr || stream.color_quality != crate::MediaColorQuality::EightBit420 { + format!( + "Software decoding presents 8-bit 4:2:0 SDR only; the negotiated {} {} profile cannot be preserved", + stream.codec.label(), + stream.color_quality.protocol_name(), + ) + } else if software.is_none() { + format!( + "The FFmpeg software decoder is unavailable for the negotiated {} stream. Select Auto in Stream settings or export diagnostics for backend probe failures.", + stream.codec.label(), + ) + } else { + return LinuxVideoSelection { + path: LinuxVideoPath::Hardware(DecoderPreference::SoftwareOnly), + use_vulkan_output: true, + fallback_reason: None, + }; + }; + LinuxVideoSelection { + path: LinuxVideoPath::Software, + use_vulkan_output: false, + fallback_reason: Some(reason), + } +} + fn select_video_path_for( requested: Option<&str>, capabilities: &LinuxCapabilitySnapshot, @@ -680,6 +724,122 @@ mod tests { } } + fn software_backend( + codec: &'static str, + color_qualities: Vec<&'static str>, + ) -> VideoBackendCapability { + VideoBackendCapability { + backend: "ffmpeg", + platform: "linux", + codecs: vec![CodecCapability { + codec, + available: true, + hdr_color_qualities: None, + color_qualities: Some(color_qualities), + hdr_supported: Some(false), + reason: None, + }], + zero_copy_modes: vec![], + available: true, + reason: None, + } + } + + #[test] + fn explicit_software_request_selects_the_bounded_cpu_decoder() { + for requested in ["software", "ffmpeg"] { + let selected = select_embedded_fallback( + requested, + crate::MediaStreamConfig::default(), + &[software_backend("h264", vec!["8bit_420"])], + ); + assert_eq!( + selected.path, + LinuxVideoPath::Hardware(DecoderPreference::SoftwareOnly) + ); + assert!(selected.use_vulkan_output); + assert!(selected.fallback_reason.is_none()); + } + for codec in ["h264", "h265", "av1"] { + let stream = crate::MediaStreamConfig { + codec: match codec { + "h265" => crate::MediaVideoCodec::H265, + "av1" => crate::MediaVideoCodec::Av1, + _ => crate::MediaVideoCodec::H264, + }, + ..crate::MediaStreamConfig::default() + }; + assert_eq!( + select_embedded_fallback( + "software", + stream, + &[software_backend(codec, vec!["8bit_420"])] + ) + .path, + LinuxVideoPath::Hardware(DecoderPreference::SoftwareOnly), + "{codec}" + ); + } + let stream = crate::MediaStreamConfig { + codec: crate::MediaVideoCodec::Av1, + ..crate::MediaStreamConfig::default() + }; + for qualities in [vec![], vec!["10bit_420"]] { + let selected = + select_embedded_fallback("software", stream, &[software_backend("av1", qualities)]); + assert_eq!(selected.path, LinuxVideoPath::Software); + assert!( + selected + .fallback_reason + .unwrap() + .contains("FFmpeg software decoder is unavailable") + ); + } + } + + #[test] + fn explicit_software_request_refuses_profiles_the_cpu_path_cannot_preserve() { + let backends = [software_backend("h265", vec!["8bit_420"])]; + for (color_quality, hdr) in [ + (crate::MediaColorQuality::EightBit420, true), + (crate::MediaColorQuality::TenBit420, false), + (crate::MediaColorQuality::EightBit444, false), + (crate::MediaColorQuality::TenBit444, false), + ] { + let stream = crate::MediaStreamConfig { + codec: crate::MediaVideoCodec::H265, + color_quality, + hdr, + ..crate::MediaStreamConfig::default() + }; + let selected = select_embedded_fallback("software", stream, &backends); + assert_eq!(selected.path, LinuxVideoPath::Software); + assert!(!selected.use_vulkan_output); + let reason = selected.fallback_reason.unwrap(); + assert!( + reason.contains("Software decoding presents 8-bit 4:2:0 SDR only"), + "{color_quality:?} hdr={hdr}: {reason}" + ); + assert!(reason.contains(color_quality.protocol_name())); + } + } + + #[test] + fn automatic_selection_never_substitutes_software_for_hardware() { + let backends = [software_backend("h264", vec!["8bit_420"])]; + for requested in ["auto", "hardware"] { + let selected = + select_embedded_fallback(requested, crate::MediaStreamConfig::default(), &backends); + assert_eq!(selected.path, LinuxVideoPath::Software, "{requested}"); + assert!( + selected + .fallback_reason + .unwrap() + .contains("No compatible embedded") + ); + } + } + fn capability(name: &'static str, available: bool) -> BackendCapability { BackendCapability { name, diff --git a/native/opennow-streamer/crates/opennow-streamer-platform/src/media.rs b/native/opennow-streamer/crates/opennow-streamer-platform/src/media.rs index 6bbb81223..6bf373a5f 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform/src/media.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform/src/media.rs @@ -1229,6 +1229,13 @@ impl MediaSession { "embedded Linux output requires a native NV12 decoder".to_owned() })); }; + if decoder_preference == opennow_streamer_platform_linux::DecoderPreference::SoftwareOnly + && (stream.hdr || stream.color_quality != crate::MediaColorQuality::EightBit420) + { + return Err( + "Software decoding presents 8-bit 4:2:0 SDR only. Select 8-bit 4:2:0, or disable HDR, or select a hardware backend.".to_owned(), + ); + } let format = opennow_streamer_platform_linux::StreamFormat::video_default( stream.width, stream.height, @@ -1329,10 +1336,12 @@ impl MediaSession { let producer = crate::LinuxGpuFrameProducer::new(8).map_err(|error| error.to_string())?; let embedded_frames = frames.clone(); let monitor_shared = Arc::clone(&shared); + let monitor_label = linux_embedded_backend_label(decoder_preference); let linux_monitor = match thread::Builder::new() .name("opennow-embedded-linux-frame-publisher".to_owned()) - .spawn(move || run_embedded_linux_monitor(monitor_shared, frames, producer)) - { + .spawn(move || { + run_embedded_linux_monitor(monitor_shared, frames, producer, monitor_label) + }) { Ok(worker) => worker, Err(error) => { shared.video.close(); @@ -3082,6 +3091,7 @@ fn run_embedded_linux_monitor( shared: Arc, publisher: crate::GraphicsFramePublisher, producer: crate::LinuxGpuFrameProducer, + backend_label: &'static str, ) { use std::time::Duration; @@ -3156,7 +3166,7 @@ fn run_embedded_linux_monitor( Ok(_) if !playback_started => { playback_started = true; let _ = shared.feedback.send(MediaFeedback::PlaybackStarted { - backend: "Linux decoder/embedded Vulkan", + backend: backend_label, }); } Ok(_) => {} @@ -3468,6 +3478,18 @@ const fn linux_decoder_name( } } +#[cfg(target_os = "linux")] +const fn linux_embedded_backend_label( + preference: opennow_streamer_platform_linux::DecoderPreference, +) -> &'static str { + match preference { + opennow_streamer_platform_linux::DecoderPreference::SoftwareOnly => { + linux_decoder_name(opennow_streamer_platform_linux::DecoderBackend::Ffmpeg) + } + _ => "Linux decoder/embedded Vulkan", + } +} + #[cfg(target_os = "linux")] const fn linux_audio_backend_name( backend: opennow_streamer_platform_linux::AudioBackend, @@ -4381,6 +4403,21 @@ mod tests { DecoderPreference::VulkanOnly, "attached shared device", ), + ( + MediaColorQuality::TenBit420, + DecoderPreference::SoftwareOnly, + "8-bit 4:2:0 SDR only", + ), + ( + MediaColorQuality::EightBit444, + DecoderPreference::SoftwareOnly, + "8-bit 4:2:0 SDR only", + ), + ( + MediaColorQuality::TenBit444, + DecoderPreference::SoftwareOnly, + "8-bit 4:2:0 SDR only", + ), ] { let (feedback, _feedback_receiver) = std::sync::mpsc::channel(); let (commands, _command_receiver) = std::sync::mpsc::channel(); diff --git a/native/opennow-streamer/crates/opennow-streamer-platform/src/runtime.rs b/native/opennow-streamer/crates/opennow-streamer-platform/src/runtime.rs index ab7d0b7ac..d3c0edf7a 100644 --- a/native/opennow-streamer/crates/opennow-streamer-platform/src/runtime.rs +++ b/native/opennow-streamer/crates/opennow-streamer-platform/src/runtime.rs @@ -213,12 +213,13 @@ impl MediaRuntime { let supported = matches!(requested, "auto" | "videotoolbox"); #[cfg(target_os = "linux")] let supported = requested == "auto" - || (matches!(requested, "vulkan" | "cuda" | "nvdec" | "vaapi" | "v4l2") - && self.video_backends().iter().any(|backend| { - backend.available - && (backend.backend == requested - || (requested == "nvdec" && backend.backend == "cuda")) - })); + || self.video_backends().iter().any(|backend| { + backend.available + && (backend.backend == requested + || (requested == "nvdec" && backend.backend == "cuda") + || (matches!(requested, "software" | "ffmpeg") + && backend.backend == "ffmpeg")) + }); #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] let supported = requested == "auto"; if supported { @@ -1986,6 +1987,31 @@ mod tests { runtime.shutdown(); } + #[cfg(target_os = "linux")] + #[test] + fn embedded_runtime_accepts_software_only_when_the_cpu_decoder_is_available() { + let (_graphics, frames) = crate::RenderThreadGraphics::new(|| {}); + let runtime = super::create_embedded_runtime(frames); + let software_available = runtime + .video_backends() + .iter() + .any(|backend| backend.backend == "ffmpeg" && backend.available); + for requested in ["software", "ffmpeg"] { + assert_eq!( + runtime.validate_backend(requested).is_ok(), + software_available, + "{requested}" + ); + } + for unsupported in ["hardware", "d3d12", "videotoolbox", "invalid"] { + assert!( + runtime.validate_backend(unsupported).is_err(), + "{unsupported}" + ); + } + runtime.shutdown(); + } + #[cfg(target_os = "windows")] #[test] fn embedded_windows_rejects_unimplemented_backend_overrides() { diff --git a/opennow-qt/qml/desktop/settings/pages/DesktopSettingsStreamPage.qml b/opennow-qt/qml/desktop/settings/pages/DesktopSettingsStreamPage.qml index 906df735d..9b663670d 100644 --- a/opennow-qt/qml/desktop/settings/pages/DesktopSettingsStreamPage.qml +++ b/opennow-qt/qml/desktop/settings/pages/DesktopSettingsStreamPage.qml @@ -165,7 +165,7 @@ Column { width: parent.width; glyph: "chip"; title: qsTr("Video backend") description: Qt.platform.os === "windows" ? qsTr("Auto uses DX11 hardware decoding. DX12 and Vulkan texture sharing are not supported by the Windows stream view yet. Applies to the next stream.") - : qsTr("Choose a supported native backend. Applies to the next stream.") + : qsTr("Choose a supported decoder. Auto never falls back to software. Applies to the next stream.") items: ShellStore.videoBackendItems() value: page.settingsScreen.valueSetting("nativeVideoBackend", "auto") onSelected: value => page.settingsScreen.setSetting("nativeVideoBackend", value) diff --git a/opennow-qt/qml/state/settings/SettingsState.qml b/opennow-qt/qml/state/settings/SettingsState.qml index 06159840d..582a91201 100644 --- a/opennow-qt/qml/state/settings/SettingsState.qml +++ b/opennow-qt/qml/state/settings/SettingsState.qml @@ -58,6 +58,13 @@ QtObject { readonly property string keyboardLayoutDescription: qsTr("Physical key mapping requested from GeForce NOW. Applies to the next session.") readonly property string interfaceLanguageDescription: qsTr("OpenNOW interface only. Community translated through Crowdin.") readonly property string colorDescription: qsTr("Availability follows the current backend, codec and HDR output. Saved unsupported choices are preserved; launch validates the profile.") + readonly property bool softwareDecodeRequested: { + const backend = String(settings.nativeVideoBackend || "auto") + if (["auto", ""].indexOf(backend) < 0) + return ["software", "ffmpeg"].indexOf(backend) >= 0 + return settings.decoderPreference === "software" + } + readonly property string languageStatusText: { if (languageState === "loading") return qsTr("Loading game languages… Saved preferences are unchanged.") if (languageState === "stale") return qsTr("Using stale cached game languages. %1").arg(languageError) @@ -246,11 +253,13 @@ QtObject { const result = [] const backends = capabilities && capabilities.videoBackends ? capabilities.videoBackends : [] + const requested = String(settings.nativeVideoBackend || "auto") + const softwareRequested = softwareDecodeRequested for (let backendIndex = 0; backendIndex < backends.length; ++backendIndex) { const backend = backends[backendIndex] - const requested = String(settings.nativeVideoBackend || "auto") - if (!backend.available || ["software", "ffmpeg"].indexOf(backend.backend) >= 0 - || (requested !== "auto" && requested !== backend.backend + const software = ["software", "ffmpeg"].indexOf(backend.backend) >= 0 + if (!backend.available || (softwareRequested ? !software : software) + || (!softwareRequested && requested !== "auto" && requested !== backend.backend && !(requested === "nvdec" && backend.backend === "cuda"))) continue const codecs = backend.codecs || [] @@ -433,15 +442,19 @@ QtObject { ? [{label:"Metal / VideoToolbox", value:"videotoolbox"}] : backends.filter(backend => ["vulkan", "cuda", "vaapi", "v4l2"].indexOf(backend.backend) >= 0) .map(backend => ({label:String(backend.backend).toUpperCase(), value:backend.backend})) + if (Qt.platform.os !== "windows" && Qt.platform.os !== "osx") + choices.push({label: qsTr("Software (CPU)"), value: "software"}) for (const choice of choices) { - const backend = backends.find(backend => backend.backend === choice.value) - result.push(Object.assign({}, choice, { + const backend = backends.find(backend => backend.backend + === (choice.value === "software" ? "ffmpeg" : choice.value)) + result.push({label: choice.label, value: choice.value, disabled: !nativeRuntimeReady || !backend || !backend.available, detail: !nativeRuntimeReady ? qsTr("Checking hardware…") : !backend ? qsTr("Not supported by this stream view") - : backend.available ? qsTr("Hardware decoding") + : backend.available ? (choice.value === "software" + ? qsTr("CPU decoding; 8-bit 4:2:0 SDR only") : qsTr("Hardware decoding")) : String(backend.reason || qsTr("Unavailable on this device")) - })) + }) } return result } diff --git a/opennow-qt/src/acceptance/SmokeAcceptance.cpp b/opennow-qt/src/acceptance/SmokeAcceptance.cpp index f263789ee..ec382a1b1 100644 --- a/opennow-qt/src/acceptance/SmokeAcceptance.cpp +++ b/opennow-qt/src/acceptance/SmokeAcceptance.cpp @@ -351,6 +351,7 @@ int AcceptanceSession::startSmokeWorkload() || m_arguments.contains(u"--smoke-push-invalidation"_s) || m_arguments.contains(u"--smoke-catalog-sync"_s) || m_arguments.contains(u"--smoke-color-format"_s) + || m_arguments.contains(u"--smoke-backend-availability"_s) || m_arguments.contains(u"--smoke-queue-drops"_s) || m_arguments.contains(u"--smoke-store-launch"_s))) { QTimer::singleShot(250, this, [this, window, fixture] { diff --git a/opennow-qt/tests/BackendAvailabilityAcceptance.qml b/opennow-qt/tests/BackendAvailabilityAcceptance.qml index 626e19215..406896942 100644 --- a/opennow-qt/tests/BackendAvailabilityAcceptance.qml +++ b/opennow-qt/tests/BackendAvailabilityAcceptance.qml @@ -14,6 +14,12 @@ QtObject { DesktopSettingsChoice { width: 900; expanded: true; value: "d3d11"; items: ShellStore.videoBackendItems() } } function check(ok, message) { if (!ok) throw new Error("Backend availability: " + message) } + function softwareReadyCapture() { + const args = Qt.application.arguments + const index = args.indexOf("--backend-availability-state") + return args.indexOf("--screenshot") >= 0 && index >= 0 && index + 1 < args.length + && args[index + 1] === "software-ready" + } function find(item, name) { if (item.objectName === name) return item for (const child of item.children || []) { @@ -71,6 +77,52 @@ QtObject { check(ShellStore.hdrDecoderAvailable(), "advertised Windows HDR conversion must enable HDR") ShellStore.settings = {codec:"h265", nativeVideoBackend:"auto", decoderPreference:"software"} check(!ShellStore.hdrDecoderAvailable(), "software decode must not enable HDR") + ShellStore.settings = {codec:"h264", nativeVideoBackend:"auto"} + ShellStore.acceptNativeCapabilities({protocolVersion:7,videoBackends:[ + {backend:"vulkan",available:false,reason:"no shared Vulkan decode device",codecs:[ + {codec:"h264",available:false,colorQualities:[]}]}, + {backend:"ffmpeg",available:true,codecs:[ + {codec:"h264",available:true,colorQualities:["8bit_420"],hdrSupported:false}]}]}) + if (Qt.platform.os === "linux") { + const items = ShellStore.videoBackendItems() + const software = items.find(item => item.value === "software") + check(software && !software.disabled, "available software decoding must be selectable") + check(software.detail.indexOf("8-bit 4:2:0 SDR") >= 0, "software limits must be visible") + check(!ShellStore.codecAvailable("h264"), + "Auto must stay hardware-only while only software is available") + ShellStore.settings = {codec:"h264", nativeVideoBackend:"auto", decoderPreference:"software"} + check(ShellStore.codecAvailable("h264"), + "a legacy software preference must resolve the software backend") + check(!ShellStore.hdrDecoderAvailable(), "a legacy software preference must not enable HDR") + ShellStore.settings = {codec:"h264", nativeVideoBackend:"auto"} + } + ShellStore.settings = {codec:"h264", nativeVideoBackend:"software"} + check(ShellStore.codecAvailable("h264"), + "an explicit software request must expose the software codecs") + check(!ShellStore.hdrDecoderAvailable(), "software decode must never advertise HDR") + if (softwareReadyCapture()) { + codecs.destroy(); backend.destroy() + ShellStore.refreshStreamerDetection() + const summary = ShellStore.streamerDetection + check(summary && (summary.availableCodecs || []).some(codec => + String(codec).toLowerCase() === "h264"), + "the production codec summary must expose the software H.264 decoder") + check(ShellStore.streamerDetectionMessage.indexOf("H264") >= 0 + || ShellStore.streamerDetectionMessage.indexOf("H.264") >= 0, + "the production detection message must expose H.264") + return true + } + ShellStore.acceptNativeCapabilities({protocolVersion:7,videoBackends:[ + {backend:"ffmpeg",available:false,reason:"FFmpeg was not built in",codecs:[ + {codec:"h264",available:false}]}]}) + if (Qt.platform.os === "linux") { + const items = ShellStore.videoBackendItems() + const software = items.find(item => item.value === "software") + check(software && software.disabled, "unavailable software decoding must stay disabled") + check(software.detail === "FFmpeg was not built in", "probe reason must be shown") + } + check(!ShellStore.codecAvailable("h264"), "an unavailable software backend exposes no codecs") + ShellStore.settings = {codec:"h264", nativeVideoBackend:"auto"} ShellStore.nativeRuntimeReady = false check(!ShellStore.hdrDecoderAvailable(), "stale HDR capabilities must fail closed") codecs.destroy(); backend.destroy()