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