From b3783dce582b4c9d4a1681da2a60143cc25f0814 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:32:16 +0100 Subject: [PATCH 1/6] fix(decode): fall back to software decode when hwaccel setup fails --- .github/workflows/sync-tests.yml | 2 +- crates/ffmpeg-hw-device/src/lib.rs | 45 ++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sync-tests.yml b/.github/workflows/sync-tests.yml index 1228d40e528..0e7cc1049ca 100644 --- a/.github/workflows/sync-tests.yml +++ b/.github/workflows/sync-tests.yml @@ -146,7 +146,7 @@ jobs: - name: Timestamp pipeline unit + property tests shell: bash run: | - cargo test --locked -p cap-timestamp -p cap-enc-ffmpeg + cargo test --locked -p cap-timestamp -p cap-enc-ffmpeg -p ffmpeg-hw-device cargo test --locked -p cap-camera-effects --lib cargo test --locked -p cap-recording --lib -- --test-threads=1 # --nocapture so a WARP-adapter notch skip prints instead of diff --git a/crates/ffmpeg-hw-device/src/lib.rs b/crates/ffmpeg-hw-device/src/lib.rs index 8691bebe316..e5d1dff2acb 100644 --- a/crates/ffmpeg-hw-device/src/lib.rs +++ b/crates/ffmpeg-hw-device/src/lib.rs @@ -9,16 +9,18 @@ use ffmpeg::{ sys::{ AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX, AVBufferRef, AVCodecContext, AVCodecHWConfig, AVHWDeviceType, AVPixelFormat, av_buffer_ref, av_buffer_unref, av_dict_free, av_dict_set, - av_hwdevice_ctx_create, av_hwframe_transfer_data, avcodec_get_hw_config, + av_hwdevice_ctx_create, av_hwframe_transfer_data, avcodec_default_get_format, + avcodec_get_hw_config, }, }; +use tracing::warn; thread_local! { static HW_PIX_FMT: Cell = const { Cell::new(AVPixelFormat::AV_PIX_FMT_NONE) }; } unsafe extern "C" fn get_format( - _: *mut AVCodecContext, + ctx: *mut AVCodecContext, pix_fmts: *const AVPixelFormat, ) -> AVPixelFormat { unsafe { @@ -36,7 +38,15 @@ unsafe extern "C" fn get_format( fmt = fmt.offset(1); } - AVPixelFormat::AV_PIX_FMT_NONE + // When the hardware format's setup fails (a busy or reset media + // engine), FFmpeg drops it from the list and asks again. Choosing + // nothing here would fail every packet, so take FFmpeg's default + // software format and keep decoding on the CPU. + warn!( + hw_pix_fmt = ?HW_PIX_FMT.get(), + "Hardware decoder setup failed, falling back to software decode" + ); + avcodec_default_get_format(ctx, pix_fmts) } } @@ -194,3 +204,32 @@ impl CodecExt for codec::codec::Codec { // } // } // } + +#[cfg(test)] +mod tests { + use super::*; + use ffmpeg::sys::{avcodec_alloc_context3, avcodec_free_context}; + + #[test] + fn get_format_falls_back_to_software_when_hardware_setup_fails() { + HW_PIX_FMT.set(AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX); + let offered = [ + AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX, + AVPixelFormat::AV_PIX_FMT_YUV420P, + AVPixelFormat::AV_PIX_FMT_NONE, + ]; + unsafe { + let mut ctx = avcodec_alloc_context3(null()); + assert_eq!( + get_format(ctx, offered.as_ptr()), + AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX + ); + // FFmpeg drops a hardware format whose setup failed and asks again. + assert_eq!( + get_format(ctx, offered[1..].as_ptr()), + AVPixelFormat::AV_PIX_FMT_YUV420P + ); + avcodec_free_context(&mut ctx); + } + } +} From 8c1fca234959e85cf0d9b0172312eb102aeaade1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:32:16 +0100 Subject: [PATCH 2/6] fix(export): restart an export on libx264 when the hardware encoder fails mid-stream --- crates/enc-ffmpeg/src/video/h264.rs | 8 +++ crates/export/Cargo.toml | 4 ++ crates/export/src/estimates.rs | 2 + crates/export/src/lib.rs | 47 +++++++++++++++ crates/export/src/mp4.rs | 91 +++++++++++++++++++++++++++-- 5 files changed, 146 insertions(+), 6 deletions(-) diff --git a/crates/enc-ffmpeg/src/video/h264.rs b/crates/enc-ffmpeg/src/video/h264.rs index 0042b4610af..6b2e6d41d77 100644 --- a/crates/enc-ffmpeg/src/video/h264.rs +++ b/crates/enc-ffmpeg/src/video/h264.rs @@ -879,6 +879,14 @@ impl H264Encoder { .map_err(|e| H264EncoderError::VideoToolboxHwInput(e.to_string())) } + /// Whether the selected codec is a hardware encoder, which can fail at + /// runtime after opening (a busy or reset media engine). + pub fn is_hardware(&self) -> bool { + self.encoder + .codec() + .is_some_and(|codec| is_hardware_h264(codec.name())) + } + pub fn conversion_requirements(&self) -> ConversionRequirements { let needs_conversion = self.input_format != self.output_format || self.input_width != self.output_width diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index b1cae02e4ec..9a214783fa2 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -3,6 +3,10 @@ name = "cap-export" version = "0.1.0" edition = "2024" +[features] +# Encoder failure injection for the cap-verify suite. Never enabled by the app. +test-hooks = [] + [dependencies] cap-utils = { path = "../utils" } cap-project = { path = "../project" } diff --git a/crates/export/src/estimates.rs b/crates/export/src/estimates.rs index 99bd390dd54..3d245e93164 100644 --- a/crates/export/src/estimates.rs +++ b/crates/export/src/estimates.rs @@ -289,6 +289,8 @@ pub async fn estimate_export( audio_cancellation: None, sample_windows: Some(windows.clone()), sample_timing: Some(timing.clone()), + #[cfg(feature = "test-hooks")] + video_encoder_failure_at: None, }; let progress_editor = editor.clone(); let progress_cancel = cancel.clone(); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 86b5514ed3a..185d9d5619a 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -254,6 +254,8 @@ impl ExporterBuilder { audio_cancellation, sample_windows: None, sample_timing: None, + #[cfg(feature = "test-hooks")] + video_encoder_failure_at: None, }) } } @@ -440,6 +442,11 @@ impl Mp4ExporterBase { pub fn uses_streaming_audio(&self) -> bool { self.0.streaming_audio.is_some() } + + #[cfg(feature = "test-hooks")] + pub fn fail_video_encoder_at(&mut self, frames: u32) { + self.0.fail_video_encoder_at(frames); + } } pub struct ExporterBase { @@ -456,9 +463,49 @@ pub struct ExporterBase { audio_cancellation: Option, sample_windows: Option, sample_timing: Option>, + #[cfg(feature = "test-hooks")] + video_encoder_failure_at: Option, } impl ExporterBase { + /// Makes the MP4 export's video encoder fail after `frames` frames the + /// way a hardware encoder does at runtime, once, so the software + /// encoder fallback runs on every machine. + #[cfg(feature = "test-hooks")] + pub fn fail_video_encoder_at(&mut self, frames: u32) { + self.video_encoder_failure_at = Some(frames); + } + + /// Reopens what an MP4 export consumes (the staged output and the + /// streaming audio with its cancellation) so the export can start over. + async fn reopen_streaming( + &mut self, + user_cancellation: Option>, + ) -> Result<(), String> { + let Some(user) = user_cancellation else { + return Ok(()); + }; + let control = ExportAudioCancellation::new(user); + let recording = self.recording_meta.clone(); + let studio = self.studio_meta.clone(); + let cancellation = control.user.clone(); + let abort = control.stop.clone(); + let preparation = tokio::task::spawn_blocking(move || { + ExportAudioPreparation::open(&recording, &studio, cancellation, abort) + }) + .await + .map_err(|error| error.to_string())? + .map_err(|error| error.to_string())?; + self.streaming_audio = Some( + preparation + .finish(&self.segments) + .map_err(|error| error.to_string())?, + ); + self.streaming_output = prepare_streaming_output(&self.output_path, true); + self.audio_cancellation = Some(control); + Ok(()) + } + pub fn total_frames(&self, fps: u32) -> u32 { let duration = cap_rendering::get_duration( &self.recordings, diff --git a/crates/export/src/mp4.rs b/crates/export/src/mp4.rs index 8395ed74090..cb893ab2b8c 100644 --- a/crates/export/src/mp4.rs +++ b/crates/export/src/mp4.rs @@ -16,7 +16,7 @@ use specta::Type; use std::{ path::PathBuf, sync::{ - Arc, Mutex, + Arc, Mutex, PoisonError, atomic::{AtomicBool, AtomicU64, Ordering}, }, time::Duration, @@ -169,6 +169,7 @@ impl ExportCompression { #[derive(Clone, Default)] struct ExportNv12Mode { stop_after_frames_sent: Option, + software_encoder: bool, record_first_queued_ms_since_pipeline: Option>, nv12_render_startup_breakdown_ms: Option>>>, @@ -294,6 +295,7 @@ impl Mp4ExportSettings { |_| true, ExportNv12Mode { stop_after_frames_sent: Some(1), + software_encoder: false, record_first_queued_ms_since_pipeline: Some(first_ms_enc), nv12_render_startup_breakdown_ms: Some(breakdown_enc), }, @@ -326,7 +328,62 @@ impl Mp4ExportSettings { output_size: (u32, u32), fps: u32, on_progress: impl FnMut(u32) -> bool + Send + 'static, + mut mode: ExportNv12Mode, + ) -> Result { + let on_progress = Arc::new(Mutex::new(on_progress)); + let user_cancellation = base + .audio_cancellation + .as_ref() + .map(|control| control.user.clone()); + loop { + let progress = on_progress.clone(); + let hardware_encoder_failed = Arc::new(AtomicBool::new(false)); + let result = self + .export_nv12_attempt( + &mut base, + output_size, + fps, + move |frame| { + let mut on_progress = + progress.lock().unwrap_or_else(PoisonError::into_inner); + (*on_progress)(frame) + }, + mode.clone(), + hardware_encoder_failed.clone(), + ) + .await; + // A hardware encoder can fail mid-stream when the media engine is + // busy or resets. The MP4 cannot switch codecs partway, so the + // export starts over once with the software encoder. + match result { + Err(error) + if !mode.software_encoder + && base.sample_timing.is_none() + && hardware_encoder_failed.load(Ordering::Relaxed) + && !user_cancellation + .as_ref() + .is_some_and(|cancel| cancel.load(Ordering::Relaxed)) => + { + warn!( + %error, + "Hardware video encoder failed during export, restarting with the software encoder" + ); + base.reopen_streaming(user_cancellation.clone()).await?; + mode.software_encoder = true; + } + result => return result, + } + } + } + + async fn export_nv12_attempt( + self, + base: &mut ExporterBase, + output_size: (u32, u32), + fps: u32, + on_progress: impl FnMut(u32) -> bool + Send + 'static, mode: ExportNv12Mode, + hardware_encoder_failed: Arc, ) -> Result { let pipeline_start = std::time::Instant::now(); let sample_timing = base.sample_timing.clone(); @@ -379,6 +436,9 @@ impl Mp4ExportSettings { let has_audio = has_recording_audio || !music.is_empty(); let record_first_queued_ms = mode.record_first_queued_ms_since_pipeline; + let software_encoder = mode.software_encoder; + #[cfg(feature = "test-hooks")] + let injected_failure_at = base.video_encoder_failure_at.filter(|_| !software_encoder); let nv12_render_startup_breakdown_ms = mode.nv12_render_startup_breakdown_ms; let project_for_audio = base.project_config.clone(); @@ -401,6 +461,7 @@ impl Mp4ExportSettings { } #[cfg(target_os = "macos")] let want_hw_input = !self.optimize_filesize + && !software_encoder && matches!( first_frame.as_ref().map(|frame| &frame.payload), Some(ExportFramePayload::Surface(_)) @@ -442,6 +503,8 @@ impl Mp4ExportSettings { .with_external_conversion(); let builder = if self.optimize_filesize { builder.with_crf(self.compression.crf_value()) + } else if software_encoder { + builder.with_encoder_priority_override(&["libx264"]) } else { builder }; @@ -465,8 +528,17 @@ impl Mp4ExportSettings { encoder.video_mut().set_packet_stats(timing.packet_stats()); } + let hardware_video = encoder.video().is_hardware(); + let video_failure = |error: String| { + if hardware_video { + hardware_encoder_failed.store(true, Ordering::Relaxed); + } + Mp4PipelineError::Failure(error) + }; + info!( zero_copy_input = encoder_is_hw, + hardware_video, "Created MP4File encoder (NV12, export settings)" ); @@ -535,7 +607,7 @@ impl Mp4ExportSettings { &mut converted_frame, Duration::MAX, ) - .map_err(|err| err.to_string())?; + .map_err(|err| video_failure(err.to_string()))?; } #[cfg(target_os = "macos")] ExportFramePayload::Surface(surface) => { @@ -545,7 +617,7 @@ impl Mp4ExportSettings { .video_encoder() .wrap_videotoolbox_pixel_buffer(surface.as_pixel_buffer_ptr()) } - .map_err(|err| err.to_string())?; + .map_err(|err| video_failure(err.to_string()))?; frame.set_pts(Some(input.frame_number as i64)); encoder .queue_video_frame_reusable( @@ -553,7 +625,7 @@ impl Mp4ExportSettings { &mut converted_frame, Duration::MAX, ) - .map_err(|err| err.to_string())?; + .map_err(|err| video_failure(err.to_string()))?; } else { fill_nv12_frame_from_surface( &mut reusable_frame, @@ -571,7 +643,7 @@ impl Mp4ExportSettings { &mut converted_frame, Duration::MAX, ) - .map_err(|err| err.to_string())?; + .map_err(|err| video_failure(err.to_string()))?; } } } @@ -579,6 +651,13 @@ impl Mp4ExportSettings { worker.write_ready(&mut encoder)?; } encoded_frames += 1; + #[cfg(feature = "test-hooks")] + if injected_failure_at == Some(encoded_frames) { + hardware_encoder_failed.store(true, Ordering::Relaxed); + return Err(Mp4PipelineError::Failure( + "Injected hardware encoder failure".to_string(), + )); + } if let Some(timing) = &sample_timing { timing.record_frame(input.timeline_frame); } @@ -617,7 +696,7 @@ impl Mp4ExportSettings { .map_err(|e| format!("Failed to finish encoding: {e}"))?; if let Err(e) = res.video_finish { - return Err(Mp4PipelineError::Failure(format!("Video encoding failed: {e}"))); + return Err(video_failure(format!("Video encoding failed: {e}"))); } if let Err(e) = res.audio_finish { return Err(Mp4PipelineError::Failure(format!("Audio encoding failed: {e}"))); From 65b2712e00b571508251c45380c5d344bb06f60f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:32:16 +0100 Subject: [PATCH 3/6] test(verify): cover the export encoder fallback with an injected encoder failure --- crates/verify/Cargo.toml | 2 +- crates/verify/tests/audio.rs | 36 ++++++++++++++++++++++-- crates/verify/tests/audio_support/mod.rs | 24 ++++++++++++++-- scripts/verify/suites.json | 12 ++++---- 4 files changed, 63 insertions(+), 11 deletions(-) diff --git a/crates/verify/Cargo.toml b/crates/verify/Cargo.toml index f81f0e55382..9266ed70529 100644 --- a/crates/verify/Cargo.toml +++ b/crates/verify/Cargo.toml @@ -33,7 +33,7 @@ cidre = { workspace = true } cap-recording = { path = "../recording", features = ["test-utils"] } cap-editor = { path = "../editor", features = ["test-hooks"] } cap-audio = { path = "../audio" } -cap-export = { path = "../export" } +cap-export = { path = "../export", features = ["test-hooks"] } cap-rendering = { path = "../rendering" } cpal = { workspace = true } tempfile = "3" diff --git a/crates/verify/tests/audio.rs b/crates/verify/tests/audio.rs index 714ee5fbbf8..8789c9697fd 100644 --- a/crates/verify/tests/audio.rs +++ b/crates/verify/tests/audio.rs @@ -161,7 +161,7 @@ const FPS: u32 = 30; const RES: (u32, u32) = (320, 180); fn export_case(spec: &FixtureSpec, variant: &Variant, path: ExportPath) -> Option { - export_case_at(spec, variant, path, FPS) + export_case_at(spec, variant, path, FPS, None) } fn export_case_at( @@ -169,6 +169,7 @@ fn export_case_at( variant: &Variant, path: ExportPath, fps: u32, + video_encoder_failure_at: Option, ) -> Option { let id = format!("audio/{}/{}/{}{fps}", spec.name, variant.edit, path.label()); if !report::selected(&id) { @@ -180,13 +181,14 @@ fn export_case_at( (variant.apply)(&mut config); let mut case = Case::new(&id); let dir = common::scratch_dir(&id); - match rt.block_on(audio_support::export( + match rt.block_on(audio_support::export_failing_encoder_at( &project, config.clone(), path, fps, RES, dir.path(), + video_encoder_failure_at, )) { Err(e) => case.problem("error", format!("export failed: {e}")), Ok(out) => match path { @@ -201,6 +203,17 @@ fn export_case_at( ); } _ => { + // libx264 tags its stream with a version SEI; the restarted + // export must come from it. + if video_encoder_failure_at.is_some() + && !std::fs::read(&out) + .is_ok_and(|bytes| bytes.windows(11).any(|w| w == b"x264 - core")) + { + case.problem( + "error", + "export after the video encoder failed was not encoded by libx264", + ); + } let model = audio_support::audio_model(&project, &config); match audio_support::exported_mono(&out) { Err(e) => case.problem("error", e), @@ -266,12 +279,31 @@ fn export_studio_sound_low_fps() { }, ExportPath::App, 10, + None, ) .into_iter() .collect(); assert!(failures.is_empty(), "{}", failures.join("\n")); } +/// A hardware video encoder can fail mid-export when the media engine is +/// busy or resets (VideoToolbox error -19092 on shared macOS runners). The +/// export restarts on libx264 and must still match the audio and video +/// models, on the streaming (app) and in-memory (plain) audio paths. +#[test] +fn encoder_fallback() { + let spec = audio_fixture::mix(report::tier()); + let variant = Variant { + edit: "encoder-fallback", + apply: full, + }; + let failures: Vec = [ExportPath::App, ExportPath::Plain] + .into_iter() + .filter_map(|path| export_case_at(&spec, &variant, path, FPS, Some(FPS * 3))) + .collect(); + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + #[test] fn studio_sound_music() { let spec = audio_fixture::mix(report::tier()); diff --git a/crates/verify/tests/audio_support/mod.rs b/crates/verify/tests/audio_support/mod.rs index 07e8500e3e3..c92fe3db9e2 100644 --- a/crates/verify/tests/audio_support/mod.rs +++ b/crates/verify/tests/audio_support/mod.rs @@ -265,6 +265,20 @@ pub async fn export( fps: u32, resolution: (u32, u32), dir: &Path, +) -> Result { + export_failing_encoder_at(project, config, path, fps, resolution, dir, None).await +} + +/// [`export`], with the MP4 video encoder failing after +/// `video_encoder_failure_at` frames as a hardware encoder does at runtime. +pub async fn export_failing_encoder_at( + project: &Project, + config: ProjectConfiguration, + path: ExportPath, + fps: u32, + resolution: (u32, u32), + dir: &Path, + video_encoder_failure_at: Option, ) -> Result { let out = dir.join(format!("out.{}", path.extension())); let builder = cap_export::ExporterBase::builder(project.path.clone()) @@ -282,17 +296,23 @@ pub async fn export( match path { ExportPath::App => { let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let base = builder + let mut base = builder .build_for_mp4(cancel) .await .map_err(|e| format!("exporter build: {e}"))?; + if let Some(frames) = video_encoder_failure_at { + base.fail_video_encoder_at(frames); + } mp4.export_prepared(base, |_| true).await } ExportPath::Plain => { - let base = builder + let mut base = builder .build() .await .map_err(|e| format!("exporter build: {e}"))?; + if let Some(frames) = video_encoder_failure_at { + base.fail_video_encoder_at(frames); + } mp4.export(base, |_| true).await } ExportPath::Mov => { diff --git a/scripts/verify/suites.json b/scripts/verify/suites.json index cdc47dadb4a..3f19a2759e2 100644 --- a/scripts/verify/suites.json +++ b/scripts/verify/suites.json @@ -191,14 +191,14 @@ "cases": { "prefix": "audio/", "fast": { - "linux": 56, - "macos": 40, - "windows": 39 + "linux": 58, + "macos": 41, + "windows": 40 }, "full": { - "linux": 58, - "macos": 58, - "windows": 58 + "linux": 60, + "macos": 60, + "windows": 60 } } }, From edeb50766254486b624390bcd1cd7c231d6e16ff Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:51:53 +0100 Subject: [PATCH 4/6] fix(decode): report a software hwaccel fallback as FFmpegSoftware --- crates/ffmpeg-hw-device/src/lib.rs | 45 +++++++++++++++++++++++++- crates/rendering/src/decoder/ffmpeg.rs | 4 ++- crates/video-decode/src/ffmpeg.rs | 16 ++++++++- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/crates/ffmpeg-hw-device/src/lib.rs b/crates/ffmpeg-hw-device/src/lib.rs index e5d1dff2acb..0bfd452cb96 100644 --- a/crates/ffmpeg-hw-device/src/lib.rs +++ b/crates/ffmpeg-hw-device/src/lib.rs @@ -1,6 +1,10 @@ use std::{ cell::Cell, ptr::{null, null_mut}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, }; use ffmpeg::{ @@ -32,6 +36,7 @@ unsafe extern "C" fn get_format( } if *fmt == HW_PIX_FMT.get() { + set_software_fallback(ctx, false); return *fmt; } @@ -46,13 +51,35 @@ unsafe extern "C" fn get_format( hw_pix_fmt = ?HW_PIX_FMT.get(), "Hardware decoder setup failed, falling back to software decode" ); + set_software_fallback(ctx, true); avcodec_default_get_format(ctx, pix_fmts) } } +/// Records `get_format`'s latest choice in the [`HwDevice`] flag that +/// [`install_get_format`] put in the codec context's `opaque`. +unsafe fn set_software_fallback(ctx: *mut AVCodecContext, software: bool) { + unsafe { + if let Some(flag) = (*ctx).opaque.cast::().cast_const().as_ref() { + flag.store(software, Ordering::Relaxed); + } + } +} + +/// Points the codec context at [`get_format`] and at the flag it records the +/// chosen format in. The flag outlives the context as long as the context is +/// freed before the [`HwDevice`] holding the flag. +unsafe fn install_get_format(context: *mut AVCodecContext, software_fallback: &Arc) { + unsafe { + (*context).get_format = Some(get_format); + (*context).opaque = Arc::as_ptr(software_fallback).cast_mut().cast(); + } +} + pub struct HwDevice { pub device_type: AVHWDeviceType, ctx: *mut AVBufferRef, + software_fallback: Arc, } fn linux_gpu_frames() -> bool { @@ -61,6 +88,12 @@ fn linux_gpu_frames() -> bool { } impl HwDevice { + /// Whether FFmpeg took a software format because the hardware setup + /// failed, so the decoder is running on the CPU despite this device. + pub fn is_software_fallback(&self) -> bool { + self.software_fallback.load(Ordering::Relaxed) + } + pub fn get_hwframe(&self, src: &Video) -> Option