Skip to main content

screencapturekit/
recording_output.rs

1//! `SCRecordingOutput` - Direct video file recording
2//!
3//! Available on macOS 15.0+.
4//! Provides direct encoding of screen capture to video files with hardware acceleration.
5//!
6//! Requires the `macos_15_0` feature flag to be enabled.
7//!
8//! ## When to Use
9//!
10//! Use `SCRecordingOutput` when you need:
11//! - Direct recording to MP4/MOV files without manual encoding
12//! - Hardware-accelerated H.264 or HEVC encoding
13//! - Recording with automatic file management
14//!
15//! For custom processing of frames, use [`SCStream`](crate::stream::SCStream) with
16//! output handlers instead.
17//!
18//! ## Example
19//!
20//! ```no_run
21//! use screencapturekit::recording_output::{
22//!     SCRecordingOutput, SCRecordingOutputConfiguration, SCRecordingOutputCodec
23//! };
24//! use screencapturekit::prelude::*;
25//! use std::path::Path;
26//!
27//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
28//! let content = SCShareableContent::get()?;
29//! let display = &content.displays()[0];
30//! let filter = SCContentFilter::create().with_display(display).with_excluding_windows(&[]).build()?;
31//! let config = SCStreamConfiguration::new()
32//!     .with_width(1920)
33//!     .with_height(1080);
34//!
35//! // Configure recording output
36//! let rec_config = SCRecordingOutputConfiguration::new()?
37//!     .with_output_url(Path::new("/tmp/recording.mp4"))?
38//!     .with_video_codec(SCRecordingOutputCodec::HEVC);
39//!
40//! let recording = SCRecordingOutput::new(&rec_config).ok_or("Failed to create recording")?;
41//!
42//! // Add to stream and start
43//! let mut stream = SCStream::new(&filter, &config)?;
44//! stream.add_recording_output(&recording)?;
45//! stream.start_capture()?;
46//!
47//! // ... record for desired duration ...
48//!
49//! stream.stop_capture()?;
50//! stream.remove_recording_output(&recording)?;
51//! # Ok(())
52//! # }
53//! ```
54
55use std::borrow::Cow;
56use std::collections::HashMap;
57use std::ffi::c_void;
58use std::path::{Path, PathBuf};
59use std::sync::atomic::{AtomicUsize, Ordering};
60use std::sync::{Arc, Mutex, PoisonError};
61
62use crate::cm::CMTime;
63use crate::error::SCError;
64
65/// Global registry for recording delegates - maps unique ID to delegate entry
66static RECORDING_DELEGATE_REGISTRY: Mutex<Option<HashMap<usize, RecordingDelegateEntry>>> =
67    Mutex::new(None);
68
69/// Counter for generating unique delegate IDs
70static NEXT_DELEGATE_ID: AtomicUsize = AtomicUsize::new(1);
71
72/// A registry entry.
73///
74/// The delegate lives behind `Arc` rather than inline in the map so a callback
75/// can clone the handle, release the global registry lock, and then run user
76/// code without any crate lock held.
77struct RecordingDelegateEntry {
78    delegate: Arc<dyn SCRecordingOutputDelegate>,
79}
80
81/// Look up a delegate handle and release the registry lock before returning.
82fn lookup_delegate(key: usize) -> Option<Arc<dyn SCRecordingOutputDelegate>> {
83    let registry = RECORDING_DELEGATE_REGISTRY
84        .lock()
85        .unwrap_or_else(PoisonError::into_inner);
86    registry
87        .as_ref()?
88        .get(&key)
89        .map(|entry| Arc::clone(&entry.delegate))
90}
91
92fn remove_delegate(key: usize) -> Option<RecordingDelegateEntry> {
93    let mut registry = RECORDING_DELEGATE_REGISTRY
94        .lock()
95        .unwrap_or_else(PoisonError::into_inner);
96    registry
97        .as_mut()
98        .and_then(|delegates| delegates.remove(&key))
99}
100
101/// An `AVVideoCodecType` identifier used for recording.
102///
103/// The identifier is open-ended: values introduced by future macOS releases
104/// remain distinct and can be passed back to the framework.
105#[derive(Debug, Clone, PartialEq, Eq, Hash)]
106pub struct SCRecordingOutputCodec(Cow<'static, str>);
107
108impl SCRecordingOutputCodec {
109    /// H.264 (`AVVideoCodecType.h264`)
110    pub const H264: Self = Self(Cow::Borrowed("avc1"));
111    /// H.265 / HEVC (`AVVideoCodecType.hevc`)
112    pub const HEVC: Self = Self(Cow::Borrowed("hvc1"));
113    /// Motion JPEG (`AVVideoCodecType.jpeg`)
114    pub const JPEG: Self = Self(Cow::Borrowed("jpeg"));
115    /// Apple `ProRes` 422 (`AVVideoCodecType.proRes422`)
116    pub const PRO_RES_422: Self = Self(Cow::Borrowed("apcn"));
117    /// Apple `ProRes` 4444 (`AVVideoCodecType.proRes4444`)
118    pub const PRO_RES_4444: Self = Self(Cow::Borrowed("ap4h"));
119    /// HEVC with an alpha channel (`AVVideoCodecType.hevcWithAlpha`)
120    pub const HEVC_WITH_ALPHA: Self = Self(Cow::Borrowed("muxa"));
121    /// Apple `ProRes` 422 HQ (`AVVideoCodecType.proRes422HQ`)
122    pub const PRO_RES_422_HQ: Self = Self(Cow::Borrowed("apch"));
123    /// Apple `ProRes` 422 LT (`AVVideoCodecType.proRes422LT`)
124    pub const PRO_RES_422_LT: Self = Self(Cow::Borrowed("apcs"));
125    /// Apple `ProRes` 422 Proxy (`AVVideoCodecType.proRes422Proxy`)
126    pub const PRO_RES_422_PROXY: Self = Self(Cow::Borrowed("apco"));
127
128    /// Construct an arbitrary codec identifier.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`InvalidRecordingIdentifier`] when the identifier contains an
133    /// interior NUL byte.
134    pub fn from_identifier(
135        identifier: impl Into<String>,
136    ) -> Result<Self, InvalidRecordingIdentifier> {
137        let identifier = identifier.into();
138        if identifier.as_bytes().contains(&0) {
139            Err(InvalidRecordingIdentifier)
140        } else {
141            Ok(Self(Cow::Owned(identifier)))
142        }
143    }
144
145    /// The underlying `AVVideoCodecType.rawValue`.
146    #[must_use]
147    pub fn identifier(&self) -> &str {
148        self.0.as_ref()
149    }
150}
151
152impl Default for SCRecordingOutputCodec {
153    fn default() -> Self {
154        Self::H264
155    }
156}
157
158impl std::fmt::Display for SCRecordingOutputCodec {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        match self.identifier() {
161            "avc1" => f.write_str("H.264"),
162            "hvc1" => f.write_str("HEVC"),
163            "jpeg" => f.write_str("JPEG"),
164            "apcn" => f.write_str("ProRes 422"),
165            "ap4h" => f.write_str("ProRes 4444"),
166            "muxa" => f.write_str("HEVC with alpha"),
167            "apch" => f.write_str("ProRes 422 HQ"),
168            "apcs" => f.write_str("ProRes 422 LT"),
169            "apco" => f.write_str("ProRes 422 Proxy"),
170            other => write!(f, "codec {other}"),
171        }
172    }
173}
174
175/// An `AVFileType` identifier used for recording output.
176#[derive(Debug, Clone, PartialEq, Eq, Hash)]
177pub struct SCRecordingOutputFileType(Cow<'static, str>);
178
179impl SCRecordingOutputFileType {
180    /// MPEG-4 file (`.mp4`)
181    pub const MP4: Self = Self(Cow::Borrowed("public.mpeg-4"));
182    /// `QuickTime` movie (`.mov`)
183    pub const MOV: Self = Self(Cow::Borrowed("com.apple.quicktime-movie"));
184    /// iTunes video (`.m4v`)
185    pub const M4V: Self = Self(Cow::Borrowed("com.apple.m4v-video"));
186    /// iTunes audio (`.m4a`)
187    pub const M4A: Self = Self(Cow::Borrowed("com.apple.m4a-audio"));
188    /// 3GPP file (`.3gp`)
189    pub const MOBILE_3GPP: Self = Self(Cow::Borrowed("public.3gpp"));
190
191    /// Construct an arbitrary file type identifier.
192    ///
193    /// # Errors
194    ///
195    /// Returns [`InvalidRecordingIdentifier`] when the identifier contains an
196    /// interior NUL byte.
197    pub fn from_identifier(
198        identifier: impl Into<String>,
199    ) -> Result<Self, InvalidRecordingIdentifier> {
200        let identifier = identifier.into();
201        if identifier.as_bytes().contains(&0) {
202            Err(InvalidRecordingIdentifier)
203        } else {
204            Ok(Self(Cow::Owned(identifier)))
205        }
206    }
207
208    /// The underlying `AVFileType.rawValue`.
209    #[must_use]
210    pub fn identifier(&self) -> &str {
211        self.0.as_ref()
212    }
213
214    /// Conventional file extension, when this crate knows the file type.
215    #[must_use]
216    pub fn extension(&self) -> Option<&'static str> {
217        match self.identifier() {
218            "public.mpeg-4" => Some("mp4"),
219            "com.apple.quicktime-movie" => Some("mov"),
220            "com.apple.m4v-video" => Some("m4v"),
221            "com.apple.m4a-audio" => Some("m4a"),
222            "public.3gpp" => Some("3gp"),
223            _ => None,
224        }
225    }
226}
227
228impl Default for SCRecordingOutputFileType {
229    fn default() -> Self {
230        Self::MP4
231    }
232}
233
234impl std::fmt::Display for SCRecordingOutputFileType {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        match self.identifier() {
237            "public.mpeg-4" => f.write_str("MP4"),
238            "com.apple.quicktime-movie" => f.write_str("MOV"),
239            "com.apple.m4v-video" => f.write_str("M4V"),
240            "com.apple.m4a-audio" => f.write_str("M4A"),
241            "public.3gpp" => f.write_str("3GPP"),
242            other => write!(f, "file type {other}"),
243        }
244    }
245}
246
247/// A recording codec or file-type identifier contained an interior NUL byte.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
249pub struct InvalidRecordingIdentifier;
250
251impl std::fmt::Display for InvalidRecordingIdentifier {
252    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253        f.write_str("recording identifier contains an interior NUL byte")
254    }
255}
256
257impl std::error::Error for InvalidRecordingIdentifier {}
258
259/// Configuration for recording output
260pub struct SCRecordingOutputConfiguration {
261    ptr: *const c_void,
262}
263
264/// Why a path could not be handed to `SCRecordingOutputConfiguration`.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
266pub enum InvalidOutputPath {
267    /// Foundation file URLs require a valid UTF-8 path.
268    NotUtf8,
269    /// The path contains an interior NUL byte, which would truncate it.
270    InteriorNul,
271}
272
273impl std::fmt::Display for InvalidOutputPath {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        match self {
276            Self::NotUtf8 => f.write_str("output path is not valid UTF-8"),
277            Self::InteriorNul => f.write_str("output path contains an interior NUL byte"),
278        }
279    }
280}
281
282impl std::error::Error for InvalidOutputPath {}
283
284impl SCRecordingOutputConfiguration {
285    /// Create a new recording output configuration
286    ///
287    /// # Errors
288    ///
289    /// Returns [`SCError::FeatureNotAvailable`] when run on macOS older than
290    /// 15.0.
291    pub fn new() -> Result<Self, SCError> {
292        if !SCRecordingOutput::is_available() {
293            return Err(SCError::feature_not_available("SCRecordingOutput", "15.0"));
294        }
295        let ptr = unsafe { crate::ffi::sc_recording_output_configuration_create() };
296        if ptr.is_null() {
297            return Err(SCError::null_pointer("SCRecordingOutputConfiguration"));
298        }
299        Ok(Self { ptr })
300    }
301
302    /// Set the output file URL.
303    ///
304    /// # Errors
305    ///
306    /// Returns an [`InvalidOutputPath`] when `path` is not valid UTF-8 or
307    /// contains an interior NUL byte.
308    pub fn with_output_url(self, path: &Path) -> Result<Self, InvalidOutputPath> {
309        let path = path.to_str().ok_or(InvalidOutputPath::NotUtf8)?;
310        let c_path = std::ffi::CString::new(path).map_err(|_| InvalidOutputPath::InteriorNul)?;
311        unsafe {
312            crate::ffi::sc_recording_output_configuration_set_output_url(self.ptr, c_path.as_ptr());
313        }
314        Ok(self)
315    }
316
317    /// Get the configured output file URL.
318    pub fn output_url(&self) -> Option<PathBuf> {
319        use std::os::unix::ffi::OsStringExt;
320
321        unsafe {
322            let path =
323                crate::ffi::sc_recording_output_configuration_get_output_path_owned(self.ptr);
324            if path.is_null() {
325                return None;
326            }
327            let bytes = std::ffi::CStr::from_ptr(path).to_bytes().to_vec();
328            crate::ffi::sc_free_string(path);
329            Some(PathBuf::from(std::ffi::OsString::from_vec(bytes)))
330        }
331    }
332
333    /// Set the video codec
334    #[must_use]
335    #[allow(clippy::needless_pass_by_value)]
336    pub fn with_video_codec(self, codec: SCRecordingOutputCodec) -> Self {
337        // SAFETY: the type's private field can only be created by constants or
338        // `from_identifier`, which rejects interior NUL bytes.
339        let codec = unsafe {
340            std::ffi::CString::from_vec_unchecked(codec.identifier().as_bytes().to_vec())
341        };
342        unsafe {
343            crate::ffi::sc_recording_output_configuration_set_video_codec_identifier(
344                self.ptr,
345                codec.as_ptr(),
346            );
347        }
348        self
349    }
350
351    /// Get the video codec
352    pub fn video_codec(&self) -> SCRecordingOutputCodec {
353        let identifier = unsafe {
354            crate::utils::ffi_string::ffi_string_owned(|| {
355                crate::ffi::sc_recording_output_configuration_get_video_codec_identifier_owned(
356                    self.ptr,
357                )
358            })
359        }
360        .unwrap_or_else(|| SCRecordingOutputCodec::H264.identifier().to_string());
361        SCRecordingOutputCodec(Cow::Owned(identifier))
362    }
363
364    /// Set the output file type
365    #[must_use]
366    #[allow(clippy::needless_pass_by_value)]
367    pub fn with_output_file_type(self, file_type: SCRecordingOutputFileType) -> Self {
368        // SAFETY: the type's private field can only be created by constants or
369        // `from_identifier`, which rejects interior NUL bytes.
370        let file_type = unsafe {
371            std::ffi::CString::from_vec_unchecked(file_type.identifier().as_bytes().to_vec())
372        };
373        unsafe {
374            crate::ffi::sc_recording_output_configuration_set_output_file_type_identifier(
375                self.ptr,
376                file_type.as_ptr(),
377            );
378        }
379        self
380    }
381
382    /// Get the output file type
383    pub fn output_file_type(&self) -> SCRecordingOutputFileType {
384        let identifier = unsafe {
385            crate::utils::ffi_string::ffi_string_owned(|| {
386                crate::ffi::sc_recording_output_configuration_get_output_file_type_identifier_owned(
387                    self.ptr,
388                )
389            })
390        }
391        .unwrap_or_else(|| SCRecordingOutputFileType::MP4.identifier().to_string());
392        SCRecordingOutputFileType(Cow::Owned(identifier))
393    }
394
395    /// Get the number of available video codecs
396    pub fn available_video_codecs_count(&self) -> usize {
397        let count = unsafe {
398            crate::ffi::sc_recording_output_configuration_get_available_video_codecs_count(self.ptr)
399        };
400        usize::try_from(count).unwrap_or(0)
401    }
402
403    /// Get all available video codecs
404    ///
405    /// Returns a vector of all video codecs that can be used for recording.
406    /// The length always matches
407    /// [`available_video_codecs_count`](Self::available_video_codecs_count):
408    /// a codec this crate has no constant for is preserved by identifier.
409    pub fn available_video_codecs(&self) -> Vec<SCRecordingOutputCodec> {
410        let count = self.available_video_codecs_count();
411        let mut codecs = Vec::with_capacity(count);
412        for i in 0..count {
413            let Ok(index) = isize::try_from(i) else { break };
414            let identifier = unsafe {
415                crate::utils::ffi_string::ffi_string_owned(|| {
416                    crate::ffi::sc_recording_output_configuration_get_available_video_codec_identifier_at_owned(
417                        self.ptr,
418                        index,
419                    )
420                })
421            };
422            if let Some(identifier) = identifier {
423                codecs.push(SCRecordingOutputCodec(Cow::Owned(identifier)));
424            }
425        }
426        codecs
427    }
428
429    /// Get the number of available output file types
430    pub fn available_output_file_types_count(&self) -> usize {
431        let count = unsafe {
432            crate::ffi::sc_recording_output_configuration_get_available_output_file_types_count(
433                self.ptr,
434            )
435        };
436        usize::try_from(count).unwrap_or(0)
437    }
438
439    /// Get all available output file types
440    ///
441    /// Returns a vector of all file types that can be used for recording
442    /// output. The length always matches
443    /// [`available_output_file_types_count`](Self::available_output_file_types_count).
444    pub fn available_output_file_types(&self) -> Vec<SCRecordingOutputFileType> {
445        let count = self.available_output_file_types_count();
446        let mut file_types = Vec::with_capacity(count);
447        for i in 0..count {
448            let Ok(index) = isize::try_from(i) else { break };
449            let identifier = unsafe {
450                crate::utils::ffi_string::ffi_string_owned(|| {
451                    crate::ffi::sc_recording_output_configuration_get_available_output_file_type_identifier_at_owned(
452                        self.ptr,
453                        index,
454                    )
455                })
456            };
457            if let Some(identifier) = identifier {
458                file_types.push(SCRecordingOutputFileType(Cow::Owned(identifier)));
459            }
460        }
461        file_types
462    }
463
464    #[must_use]
465    pub fn as_ptr(&self) -> *const c_void {
466        self.ptr
467    }
468}
469
470crate::utils::retained::sc_retained!(
471    SCRecordingOutputConfiguration,
472    field = ptr,
473    release = crate::ffi::sc_recording_output_configuration_release,
474);
475
476impl Clone for SCRecordingOutputConfiguration {
477    /// Deep-copies the underlying `SCRecordingOutputConfiguration`.
478    ///
479    /// The native object is a mutable class. Retaining it would make every
480    /// clone an alias — reconfiguring one handle would silently reconfigure
481    /// the others, and two threads configuring "their own" clone would race on
482    /// the same non-atomic properties, which `Send`/`Sync` on this type
483    /// promises cannot happen.
484    fn clone(&self) -> Self {
485        Self {
486            ptr: unsafe { crate::ffi::sc_recording_output_configuration_copy(self.ptr) },
487        }
488    }
489}
490
491impl std::fmt::Debug for SCRecordingOutputConfiguration {
492    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
493        f.debug_struct("SCRecordingOutputConfiguration")
494            .field("video_codec", &format_args!("{}", self.video_codec()))
495            .field("file_type", &format_args!("{}", self.output_file_type()))
496            .finish()
497    }
498}
499
500/// Delegate for recording output events
501///
502/// Implement this trait to receive notifications about recording lifecycle events.
503/// Callbacks may arrive on different system threads, so implementations must
504/// synchronize shared mutable state internally.
505///
506/// # Examples
507///
508/// ## Using a struct
509///
510/// ```
511/// use screencapturekit::recording_output::SCRecordingOutputDelegate;
512///
513/// struct MyRecordingDelegate;
514///
515/// impl SCRecordingOutputDelegate for MyRecordingDelegate {
516///     fn recording_did_start(&self) {
517///         println!("Recording started!");
518///     }
519///     fn recording_did_fail(&self, error: String) {
520///         eprintln!("Recording failed: {}", error);
521///     }
522///     fn recording_did_finish(&self) {
523///         println!("Recording finished!");
524///     }
525/// }
526/// ```
527///
528/// ## Using closures
529///
530/// Use [`RecordingCallbacks`] to create a delegate from closures:
531///
532/// ```rust,no_run
533/// use screencapturekit::recording_output::{
534///     SCRecordingOutput, SCRecordingOutputConfiguration, RecordingCallbacks
535/// };
536/// use std::path::Path;
537///
538/// let config = SCRecordingOutputConfiguration::new().expect("create recording configuration")
539///     .with_output_url(Path::new("/tmp/recording.mp4")).expect("output path is valid UTF-8 without NUL bytes");
540///
541/// let delegate = RecordingCallbacks::new()
542///     .on_start(|| println!("Started!"))
543///     .on_finish(|| println!("Finished!"))
544///     .on_fail(|e| eprintln!("Error: {}", e));
545///
546/// let recording = SCRecordingOutput::new_with_delegate(&config, delegate);
547/// ```
548pub trait SCRecordingOutputDelegate: Send + Sync + 'static {
549    /// Called when recording starts successfully
550    fn recording_did_start(&self) {}
551    /// Called when recording fails with an error
552    fn recording_did_fail(&self, _error: String) {}
553    /// Called when recording finishes successfully
554    fn recording_did_finish(&self) {}
555}
556
557/// Builder for closure-based recording delegate
558///
559/// Provides a convenient way to create a recording delegate using closures
560/// instead of implementing the [`SCRecordingOutputDelegate`] trait.
561///
562/// # Examples
563///
564/// ```rust,no_run
565/// use screencapturekit::recording_output::{
566///     SCRecordingOutput, SCRecordingOutputConfiguration, RecordingCallbacks
567/// };
568/// use std::path::Path;
569///
570/// let config = SCRecordingOutputConfiguration::new().expect("create recording configuration")
571///     .with_output_url(Path::new("/tmp/recording.mp4")).expect("output path is valid UTF-8 without NUL bytes");
572///
573/// // Create delegate with all callbacks
574/// let delegate = RecordingCallbacks::new()
575///     .on_start(|| println!("Recording started!"))
576///     .on_finish(|| println!("Recording finished!"))
577///     .on_fail(|error| eprintln!("Recording failed: {}", error));
578///
579/// let recording = SCRecordingOutput::new_with_delegate(&config, delegate);
580///
581/// // Or just handle specific events
582/// let delegate = RecordingCallbacks::new()
583///     .on_fail(|error| eprintln!("Error: {}", error));
584/// ```
585#[allow(clippy::struct_field_names)]
586pub struct RecordingCallbacks {
587    on_start: Option<Box<dyn Fn() + Send + Sync + 'static>>,
588    on_fail: Option<Box<dyn Fn(String) + Send + Sync + 'static>>,
589    on_finish: Option<Box<dyn Fn() + Send + Sync + 'static>>,
590}
591
592impl RecordingCallbacks {
593    /// Create a new empty callbacks builder
594    #[must_use]
595    pub fn new() -> Self {
596        Self {
597            on_start: None,
598            on_fail: None,
599            on_finish: None,
600        }
601    }
602
603    /// Set the callback for when recording starts
604    #[must_use]
605    pub fn on_start<F>(mut self, f: F) -> Self
606    where
607        F: Fn() + Send + Sync + 'static,
608    {
609        self.on_start = Some(Box::new(f));
610        self
611    }
612
613    /// Set the callback for when recording fails
614    #[must_use]
615    pub fn on_fail<F>(mut self, f: F) -> Self
616    where
617        F: Fn(String) + Send + Sync + 'static,
618    {
619        self.on_fail = Some(Box::new(f));
620        self
621    }
622
623    /// Set the callback for when recording finishes
624    #[must_use]
625    pub fn on_finish<F>(mut self, f: F) -> Self
626    where
627        F: Fn() + Send + Sync + 'static,
628    {
629        self.on_finish = Some(Box::new(f));
630        self
631    }
632}
633
634impl Default for RecordingCallbacks {
635    fn default() -> Self {
636        Self::new()
637    }
638}
639
640impl std::fmt::Debug for RecordingCallbacks {
641    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
642        f.debug_struct("RecordingCallbacks")
643            .field("on_start", &self.on_start.is_some())
644            .field("on_fail", &self.on_fail.is_some())
645            .field("on_finish", &self.on_finish.is_some())
646            .finish()
647    }
648}
649
650impl SCRecordingOutputDelegate for RecordingCallbacks {
651    fn recording_did_start(&self) {
652        if let Some(ref f) = self.on_start {
653            f();
654        }
655    }
656
657    fn recording_did_fail(&self, error: String) {
658        if let Some(ref f) = self.on_fail {
659            f(error);
660        }
661    }
662
663    fn recording_did_finish(&self) {
664        if let Some(ref f) = self.on_finish {
665            f();
666        }
667    }
668}
669
670/// Recording output for direct video file encoding
671///
672/// Available on macOS 15.0+
673pub struct SCRecordingOutput {
674    ptr: *const c_void,
675    /// ID into the delegate registry, if a delegate was set
676    delegate_id: Option<usize>,
677}
678
679// C callback trampolines for delegate - ctx is the delegate registry id as usize.
680//
681// Each body is fully enclosed in a panic barrier: a panic escaping an
682// `extern "C"` function is undefined behaviour, and the registry lookup itself
683// can panic (allocation, poisoned-lock recovery) before user code even runs.
684// The registry lock is always released before the user delegate is invoked —
685// see `RecordingDelegateEntry`.
686extern "C" fn recording_started_callback(ctx: *mut c_void) {
687    crate::utils::panic_safe::catch_user_panic(
688        "SCRecordingOutputDelegate::recording_did_start",
689        || {
690            if let Some(delegate) = lookup_delegate(ctx as usize) {
691                delegate.recording_did_start();
692            }
693        },
694    );
695}
696
697extern "C" fn recording_failed_callback(ctx: *mut c_void, error_code: i32, error: *const i8) {
698    crate::utils::panic_safe::catch_user_panic(
699        "SCRecordingOutputDelegate::recording_did_fail",
700        || {
701            let error_str = if error.is_null() {
702                String::from("Unknown error")
703            } else {
704                unsafe { std::ffi::CStr::from_ptr(error) }
705                    .to_string_lossy()
706                    .into_owned()
707            };
708
709            // Include error code in the message if it's a known SCStreamError
710            let full_error = if error_code == 0 {
711                error_str
712            } else {
713                crate::error::SCStreamErrorCode::from_raw(error_code).map_or_else(
714                    || format!("{error_str} (code: {error_code})"),
715                    |code| format!("{error_str} ({code})"),
716                )
717            };
718            if let Some(delegate) = lookup_delegate(ctx as usize) {
719                delegate.recording_did_fail(full_error);
720            }
721        },
722    );
723}
724
725extern "C" fn recording_finished_callback(ctx: *mut c_void) {
726    crate::utils::panic_safe::catch_user_panic(
727        "SCRecordingOutputDelegate::recording_did_finish",
728        || {
729            if let Some(delegate) = lookup_delegate(ctx as usize) {
730                delegate.recording_did_finish();
731            }
732        },
733    );
734}
735
736extern "C" fn recording_context_release_callback(ctx: *mut c_void) {
737    crate::utils::panic_safe::catch_user_panic("SCRecordingOutputDelegate::release", || {
738        drop(remove_delegate(ctx as usize));
739    });
740}
741
742impl SCRecordingOutput {
743    /// Whether recording-output APIs are available on this system.
744    #[must_use]
745    pub fn is_available() -> bool {
746        unsafe { crate::ffi::sc_recording_output_is_available() }
747    }
748
749    /// Create a new recording output with configuration
750    ///
751    /// # Errors
752    /// Returns None if the system is not macOS 15.0+ or creation fails
753    pub fn new(config: &SCRecordingOutputConfiguration) -> Option<Self> {
754        if !Self::is_available() {
755            return None;
756        }
757        let config = config.clone();
758        let ptr = unsafe { crate::ffi::sc_recording_output_create(config.as_ptr()) };
759        if ptr.is_null() {
760            None
761        } else {
762            Some(Self {
763                ptr,
764                delegate_id: None,
765            })
766        }
767    }
768
769    /// Create a new recording output with configuration and delegate
770    ///
771    /// The delegate receives callbacks for recording lifecycle events:
772    /// - `recording_did_start` - Called when recording begins
773    /// - `recording_did_fail` - Called if recording fails with an error
774    /// - `recording_did_finish` - Called when recording completes successfully
775    ///
776    /// # Errors
777    /// Returns None if the system is not macOS 15.0+ or creation fails
778    pub fn new_with_delegate<D: SCRecordingOutputDelegate>(
779        config: &SCRecordingOutputConfiguration,
780        delegate: D,
781    ) -> Option<Self> {
782        if !Self::is_available() {
783            return None;
784        }
785        let entry = RecordingDelegateEntry {
786            delegate: Arc::new(delegate),
787        };
788        let delegate_id = {
789            let mut registry = RECORDING_DELEGATE_REGISTRY
790                .lock()
791                .unwrap_or_else(PoisonError::into_inner);
792            let delegates = registry.get_or_insert_with(HashMap::new);
793            loop {
794                let id = NEXT_DELEGATE_ID.fetch_add(1, Ordering::Relaxed);
795                if id != 0 && !delegates.contains_key(&id) {
796                    delegates.insert(id, entry);
797                    drop(registry);
798                    break id;
799                }
800            }
801        };
802
803        // Use delegate_id as context
804        let ctx = delegate_id as *mut c_void;
805        let config = config.clone();
806
807        let ptr = unsafe {
808            crate::ffi::sc_recording_output_create_with_delegate(
809                config.as_ptr(),
810                Some(recording_started_callback),
811                Some(recording_failed_callback),
812                Some(recording_finished_callback),
813                Some(recording_context_release_callback),
814                ctx,
815            )
816        };
817
818        if ptr.is_null() {
819            drop(remove_delegate(delegate_id));
820            None
821        } else {
822            Some(Self {
823                ptr,
824                delegate_id: Some(delegate_id),
825            })
826        }
827    }
828
829    /// Get the current recorded duration
830    pub fn recorded_duration(&self) -> CMTime {
831        let mut value: i64 = 0;
832        let mut timescale: i32 = 0;
833        unsafe {
834            crate::ffi::sc_recording_output_get_recorded_duration(
835                self.ptr,
836                &raw mut value,
837                &raw mut timescale,
838            );
839        }
840        CMTime::new(value, timescale)
841    }
842
843    /// Get the current recorded file size in bytes
844    pub fn recorded_file_size(&self) -> i64 {
845        unsafe { crate::ffi::sc_recording_output_get_recorded_file_size(self.ptr) }
846    }
847
848    #[must_use]
849    pub fn as_ptr(&self) -> *const c_void {
850        self.ptr
851    }
852}
853
854impl Clone for SCRecordingOutput {
855    fn clone(&self) -> Self {
856        unsafe {
857            Self {
858                ptr: crate::ffi::sc_recording_output_retain(self.ptr),
859                delegate_id: self.delegate_id,
860            }
861        }
862    }
863}
864
865impl std::fmt::Debug for SCRecordingOutput {
866    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
867        f.debug_struct("SCRecordingOutput")
868            .field("recorded_duration", &self.recorded_duration())
869            .field("recorded_file_size", &self.recorded_file_size())
870            .field("has_delegate", &self.delegate_id.is_some())
871            .finish_non_exhaustive()
872    }
873}
874
875impl Drop for SCRecordingOutput {
876    fn drop(&mut self) {
877        if !self.ptr.is_null() {
878            unsafe {
879                crate::ffi::sc_recording_output_release(self.ptr);
880            }
881        }
882    }
883}
884
885// Safety: SCRecordingOutput wraps an Objective-C object that is thread-safe
886unsafe impl Send for SCRecordingOutput {}
887unsafe impl Sync for SCRecordingOutput {}
888
889// Safety: SCRecordingOutputConfiguration wraps an Objective-C object that is thread-safe
890unsafe impl Send for SCRecordingOutputConfiguration {}
891unsafe impl Sync for SCRecordingOutputConfiguration {}