From 0708fdfd6729a88ded230d93c8668c9426e716ed Mon Sep 17 00:00:00 2001 From: aasishdairel Date: Fri, 23 Oct 2026 00:57:05 +0530 Subject: [PATCH 1/3] fix(rendering): add opt-in macOS software codec mode VideoToolbox can schedule H.264 decode and encode on the integrated GPU even when the renderer is on the discrete GPU. Add CAP_MACOS_SOFTWARE_CODECS=1 to force FFmpeg software decode and libx264 export so the integrated GPU stays idle. The default codec behavior is unchanged; this only changes codec choice when the variable is set. --- crates/enc-ffmpeg/src/video/h264.rs | 17 +++++++++++------ crates/export/src/mp4.rs | 1 + crates/rendering/src/decoder/mod.rs | 14 ++++++++++++-- crates/rendering/src/lib.rs | 15 +++++++++++++++ 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/crates/enc-ffmpeg/src/video/h264.rs b/crates/enc-ffmpeg/src/video/h264.rs index 6b2e6d41d77..fa9724ef7eb 100644 --- a/crates/enc-ffmpeg/src/video/h264.rs +++ b/crates/enc-ffmpeg/src/video/h264.rs @@ -1201,12 +1201,17 @@ fn get_encoder_priority_with_override( } 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" - ) - }) + 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") + || (cfg!(target_os = "macos") && enabled("CAP_MACOS_SOFTWARE_CODECS")) } fn export_encoder_priority_override( diff --git a/crates/export/src/mp4.rs b/crates/export/src/mp4.rs index 8c4fb4cae1b..4e875e76f0c 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 419dc595644..957496c1fda 100644 --- a/crates/rendering/src/decoder/mod.rs +++ b/crates/rendering/src/decoder/mod.rs @@ -1473,14 +1473,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 77974d98331..0397201167d 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()); From 532ae208c91e3d52f4e280cfa5e9a063692998c0 Mon Sep 17 00:00:00 2001 From: aasishdairel Date: Fri, 23 Oct 2026 01:53:59 +0530 Subject: [PATCH 2/3] fix(rendering): honor software codecs in managed decoders The preparing editor preview uses spawn_managed_decoder, so CAP_MACOS_SOFTWARE_CODECS only affected spawn_decoder. Apply the same override to the managed decoder path so preview and camera decoders also avoid VideoToolbox on the integrated GPU. --- crates/rendering/src/decoder/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/rendering/src/decoder/mod.rs b/crates/rendering/src/decoder/mod.rs index 957496c1fda..b0445765363 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 From cc24d9a91ff3c643d34ab4965f3fd7151612f716 Mon Sep 17 00:00:00 2001 From: aasishdairel Date: Fri, 23 Oct 2026 19:52:52 +0530 Subject: [PATCH 3/3] fix(enc-ffmpeg): limit software codec env to export encoding CAP_MACOS_SOFTWARE_CODECS was forcing libx264 for live Studio recording encoding as well as export. Live macOS recording builds its encoder through H264EncoderBuilder, so setting the variable could drop frames at high resolutions. Gate the new env var on is_export and keep CAP_EXPORT_FORCE_SOFTWARE_ENCODER unchanged. --- crates/enc-ffmpeg/src/video/h264.rs | 61 ++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/crates/enc-ffmpeg/src/video/h264.rs b/crates/enc-ffmpeg/src/video/h264.rs index fa9724ef7eb..f800f7ab446 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,7 +1200,7 @@ fn get_encoder_priority_with_override( override_priority.unwrap_or_else(|| get_default_encoder_priority(config)) } -fn force_software_encoder() -> bool { +fn force_software_encoder(is_export: bool) -> bool { let enabled = |name: &str| { std::env::var(name).is_ok_and(|value| { matches!( @@ -1211,7 +1211,7 @@ fn force_software_encoder() -> bool { }; enabled("CAP_EXPORT_FORCE_SOFTWARE_ENCODER") - || (cfg!(target_os = "macos") && enabled("CAP_MACOS_SOFTWARE_CODECS")) + || (is_export && cfg!(target_os = "macos") && enabled("CAP_MACOS_SOFTWARE_CODECS")) } fn export_encoder_priority_override( @@ -1682,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); @@ -1715,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(); @@ -1742,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() {