diff --git a/crates/enc-ffmpeg/src/video/h264.rs b/crates/enc-ffmpeg/src/video/h264.rs index 6b2e6d41d7..f800f7ab44 100644 --- a/crates/enc-ffmpeg/src/video/h264.rs +++ b/crates/enc-ffmpeg/src/video/h264.rs @@ -341,7 +341,7 @@ impl H264EncoderBuilder { height: output_height, }); } - if force_software_encoder() + if force_software_encoder(self.is_export) || requires_software_encoder(&input_config, self.preset, self.is_export) { return Err(H264EncoderError::VideoToolboxHwInput( @@ -1189,7 +1189,7 @@ fn get_encoder_priority_with_override( override_priority: Option<&'static [&'static str]>, is_export: bool, ) -> &'static [&'static str] { - if force_software_encoder() { + if force_software_encoder(is_export) { return &["libx264"]; } @@ -1200,13 +1200,18 @@ fn get_encoder_priority_with_override( override_priority.unwrap_or_else(|| get_default_encoder_priority(config)) } -fn force_software_encoder() -> bool { - std::env::var("CAP_EXPORT_FORCE_SOFTWARE_ENCODER").is_ok_and(|value| { - matches!( - value.to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) - }) +fn force_software_encoder(is_export: bool) -> bool { + let enabled = |name: &str| { + std::env::var(name).is_ok_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + }; + + enabled("CAP_EXPORT_FORCE_SOFTWARE_ENCODER") + || (is_export && cfg!(target_os = "macos") && enabled("CAP_MACOS_SOFTWARE_CODECS")) } fn export_encoder_priority_override( @@ -1677,6 +1682,34 @@ const MAX_BITRATE: usize = 1_000_000_000; mod self_test_tests { use super::*; + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var_os(key); + // SAFETY: tests in this module serialize environment access with ENV_LOCK. + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + // SAFETY: tests in this module serialize environment access with ENV_LOCK. + unsafe { + match &self.previous { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + } + fn yuv420p_frame(y: u8, u: u8, v: u8) -> frame::Video { let mut frame = frame::Video::new(format::Pixel::YUV420P, 64, 48); frame.data_mut(0).fill(y); @@ -1710,7 +1743,8 @@ mod self_test_tests { #[cfg(target_os = "macos")] #[test] fn videotoolbox_export_uses_the_default_keyframe_interval() { - if force_software_encoder() { + let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + if force_software_encoder(true) { return; } ffmpeg::init().ok(); @@ -1737,6 +1771,28 @@ mod self_test_tests { assert_eq!(realtime.get("g"), None); } + #[cfg(target_os = "macos")] + #[test] + fn software_codec_env_only_forces_export_encoder_priority() { + let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _export_guard = EnvVarGuard::set("CAP_EXPORT_FORCE_SOFTWARE_ENCODER", "0"); + let config = VideoInfo::from_raw(cap_media_info::RawVideoFormat::Nv12, 1920, 1080, 30); + let recording = + get_encoder_priority_with_override(&config, H264Preset::Medium, None, false); + assert_ne!(recording, &["libx264"]); + + let _software_guard = EnvVarGuard::set("CAP_MACOS_SOFTWARE_CODECS", "1"); + + assert_eq!( + get_encoder_priority_with_override(&config, H264Preset::Medium, None, false), + recording + ); + assert_eq!( + get_encoder_priority_with_override(&config, H264Preset::Medium, None, true), + &["libx264"] + ); + } + #[cfg(target_os = "linux")] #[test] fn linux_nvidia_device_prefers_nvenc_and_keeps_software_fallback() { diff --git a/crates/export/src/mp4.rs b/crates/export/src/mp4.rs index 8c4fb4cae1..4e875e76f0 100644 --- a/crates/export/src/mp4.rs +++ b/crates/export/src/mp4.rs @@ -330,6 +330,7 @@ impl Mp4ExportSettings { on_progress: impl FnMut(u32) -> bool + Send + 'static, mut mode: ExportNv12Mode, ) -> Result { + mode.software_encoder |= cap_rendering::software_video_codecs_requested(); let on_progress = Arc::new(Mutex::new(on_progress)); let user_cancellation = base .audio_cancellation diff --git a/crates/rendering/src/decoder/mod.rs b/crates/rendering/src/decoder/mod.rs index 419dc59564..b044576536 100644 --- a/crates/rendering/src/decoder/mod.rs +++ b/crates/rendering/src/decoder/mod.rs @@ -1375,6 +1375,7 @@ pub fn spawn_managed_decoder<'a>( offset: f64, use_hw_acceleration: bool, ) -> Result { + let use_hw_acceleration = use_hw_acceleration && !crate::software_video_codecs_requested(); let input = ManagedVideoInput { source, paths: paths @@ -1473,14 +1474,24 @@ pub async fn spawn_decoder( #[cfg(target_os = "macos")] { - if force_ffmpeg || path.is_dir() { + let software_codecs = crate::software_video_codecs_requested(); + if force_ffmpeg || path.is_dir() || software_codecs { info!( force_ffmpeg, segmented = path.is_dir(), + software_codecs, "Video '{}' using FFmpeg decoder", name ); - return spawn_ffmpeg_decoder(name, path, fps, offset, timeout_duration, true).await; + return spawn_ffmpeg_decoder( + name, + path, + fps, + offset, + timeout_duration, + !software_codecs, + ) + .await; } let avasset_result = { diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 77974d9833..0397201167 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -303,6 +303,21 @@ pub fn force_software_wgpu_adapter() -> bool { }) } +/// `CAP_MACOS_SOFTWARE_CODECS=1` forces video decode and encode onto software +/// codecs on macOS. VideoToolbox schedules hardware H.264/HEVC work on the +/// integrated GPU on dual-GPU Macs, even when the renderer is on the discrete +/// GPU. Software codecs keep the integrated GPU idle at the cost of CPU time. +/// This only changes codec choice; the renderer keeps its own GPU selection. +pub fn software_video_codecs_requested() -> bool { + cfg!(target_os = "macos") + && std::env::var("CAP_MACOS_SOFTWARE_CODECS").is_ok_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + pub fn create_wgpu_instance_sync() -> wgpu::Instance { #[cfg(not(target_os = "windows"))] let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());