Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.4.0](https://github.com/doom-fish/screencapturekit-rs/compare/v1.3.0...v1.4.0) - 2025-12-04

### Added

- *(cpal)* add zero-copy audio stream
- *(cpal)* add audio input stream and cpal integration
- *(cpal)* add optional cpal audio adapter
- *(screenshot)* add multi-format image saving support
- *(cm)* add frame info accessors to CMSampleBuffer

### Other

- fix formatting in pixel_buffer.rs
- remove cpal dependency and update dev dependencies
- *(cpal)* add cpal adapter tests and example
- update README to use macos_26_0 as latest feature
- fix rustfmt formatting issues
- *(tests)* rename sync_completion_tests to completion_tests

## [1.3.0](https://github.com/doom-fish/screencapturekit-rs/compare/v1.2.0...v1.3.0) - 2025-11-30

### Added
Expand Down
12 changes: 6 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "screencapturekit"
version = "1.3.0"
version = "1.4.0"
edition = "2021"
license = "MIT OR Apache-2.0"
homepage = "https://github.com/doom-fish/screencapturekit-rs"
Expand Down Expand Up @@ -53,11 +53,11 @@ macos_26_0 = ["macos_15_2"]
[dependencies]

[dev-dependencies]
png = "0.17"
tokio = { version = "1.40", features = ["sync", "rt", "rt-multi-thread", "macros", "test-util"] }
metal = "0.29"
cocoa = "0.25"
core-graphics-types = "0.1"
png = "0.18"
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "macros", "test-util"] }
metal = "0.32"
cocoa = "0.26"
core-graphics-types = "0.2"
objc = "0.2"
winit = "0.28"
raw-window-handle = "0.5"
Expand Down
17 changes: 17 additions & 0 deletions src/cm/ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,23 @@ extern "C" {
) -> *mut std::ffi::c_void;
pub fn cm_format_description_release(format_description: *mut std::ffi::c_void);

// CMFormatDescription Audio APIs
pub fn cm_format_description_get_audio_sample_rate(
format_description: *mut std::ffi::c_void,
) -> f64;
pub fn cm_format_description_get_audio_channel_count(
format_description: *mut std::ffi::c_void,
) -> u32;
pub fn cm_format_description_get_audio_bits_per_channel(
format_description: *mut std::ffi::c_void,
) -> u32;
pub fn cm_format_description_get_audio_bytes_per_frame(
format_description: *mut std::ffi::c_void,
) -> u32;
pub fn cm_format_description_get_audio_format_flags(
format_description: *mut std::ffi::c_void,
) -> u32;

// Hash functions
pub fn cm_sample_buffer_hash(sample_buffer: *mut std::ffi::c_void) -> usize;
pub fn cv_pixel_buffer_hash(pixel_buffer: *mut std::ffi::c_void) -> usize;
Expand Down
84 changes: 84 additions & 0 deletions src/cm/format_description.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,90 @@ impl CMFormatDescription {
pub fn is_alac(&self) -> bool {
self.media_subtype() == codec_types::ALAC
}

// Audio format description methods

/// Get the audio sample rate in Hz
///
/// Returns `None` if this is not an audio format description.
pub fn audio_sample_rate(&self) -> Option<f64> {
if !self.is_audio() {
return None;
}
let rate = unsafe { ffi::cm_format_description_get_audio_sample_rate(self.0) };
if rate > 0.0 {
Some(rate)
} else {
None
}
}

/// Get the number of audio channels
///
/// Returns `None` if this is not an audio format description.
pub fn audio_channel_count(&self) -> Option<u32> {
if !self.is_audio() {
return None;
}
let count = unsafe { ffi::cm_format_description_get_audio_channel_count(self.0) };
if count > 0 {
Some(count)
} else {
None
}
}

/// Get the bits per audio channel
///
/// Returns `None` if this is not an audio format description.
pub fn audio_bits_per_channel(&self) -> Option<u32> {
if !self.is_audio() {
return None;
}
let bits = unsafe { ffi::cm_format_description_get_audio_bits_per_channel(self.0) };
if bits > 0 {
Some(bits)
} else {
None
}
}

/// Get the bytes per audio frame
///
/// Returns `None` if this is not an audio format description.
pub fn audio_bytes_per_frame(&self) -> Option<u32> {
if !self.is_audio() {
return None;
}
let bytes = unsafe { ffi::cm_format_description_get_audio_bytes_per_frame(self.0) };
if bytes > 0 {
Some(bytes)
} else {
None
}
}

/// Get the audio format flags
///
/// Returns `None` if this is not an audio format description.
pub fn audio_format_flags(&self) -> Option<u32> {
if !self.is_audio() {
return None;
}
Some(unsafe { ffi::cm_format_description_get_audio_format_flags(self.0) })
}

/// Check if audio is float format (based on format flags)
pub fn audio_is_float(&self) -> bool {
// kAudioFormatFlagIsFloat = 1
self.audio_format_flags().is_some_and(|f| f & 1 != 0)
}

/// Check if audio is big-endian (based on format flags)
pub fn audio_is_big_endian(&self) -> bool {
// kAudioFormatFlagIsBigEndian = 2
self.audio_format_flags().is_some_and(|f| f & 2 != 0)
}
}

impl Clone for CMFormatDescription {
Expand Down
40 changes: 40 additions & 0 deletions src/output/pixel_buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,46 @@ impl PixelBufferLockGuard<'_> {
self.bytes_per_row
}

/// Get the number of planes in the pixel buffer
///
/// Returns 0 for non-planar formats (e.g., BGRA).
/// Returns 2 for bi-planar formats like NV12 (`YCbCr_420v`).
pub fn plane_count(&self) -> usize {
unsafe { crate::cm::ffi::cv_pixel_buffer_get_plane_count(self.buffer_ptr) }
}

/// Get the width of a specific plane
pub fn width_of_plane(&self, plane_index: usize) -> usize {
unsafe { crate::cm::ffi::cv_pixel_buffer_get_width_of_plane(self.buffer_ptr, plane_index) }
}

/// Get the height of a specific plane
pub fn height_of_plane(&self, plane_index: usize) -> usize {
unsafe { crate::cm::ffi::cv_pixel_buffer_get_height_of_plane(self.buffer_ptr, plane_index) }
}

/// Get the base address of a specific plane
pub fn base_address_of_plane(&self, plane_index: usize) -> Option<*const u8> {
unsafe {
let ptr = crate::cm::ffi::cv_pixel_buffer_get_base_address_of_plane(
self.buffer_ptr,
plane_index,
);
if ptr.is_null() {
None
} else {
Some(ptr.cast::<u8>())
}
}
}

/// Get the bytes per row of a specific plane
pub fn bytes_per_row_of_plane(&self, plane_index: usize) -> usize {
unsafe {
crate::cm::ffi::cv_pixel_buffer_get_bytes_per_row_of_plane(self.buffer_ptr, plane_index)
}
}

/// Get raw pointer to buffer data
pub fn as_ptr(&self) -> *const u8 {
self.base_address.as_ptr()
Expand Down
70 changes: 61 additions & 9 deletions src/stream/sc_stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ extern "C" fn sample_handler(
/// ```
pub struct SCStream {
ptr: *const c_void,
/// Handler IDs registered by this stream instance, keyed by output type
handler_ids: Vec<(usize, SCStreamOutputType)>,
}

unsafe impl Send for SCStream {}
Expand Down Expand Up @@ -166,7 +168,10 @@ impl SCStream {
ffi::sc_stream_create(filter.as_ptr(), configuration.as_ptr(), error_callback)
};
assert!(!ptr.is_null(), "Swift bridge returned null stream");
Self { ptr }
Self {
ptr,
handler_ids: Vec::new(),
}
}

pub fn new_with_delegate(
Expand Down Expand Up @@ -327,8 +332,15 @@ impl SCStream {
};

if ok {
self.handler_ids.push((handler_id, of_type));
Some(handler_id)
} else {
// Remove from registry since Swift rejected it
HANDLER_REGISTRY
.lock()
.unwrap()
.as_mut()
.map(|handlers| handlers.remove(&handler_id));
None
}
}
Expand All @@ -347,13 +359,29 @@ impl SCStream {
/// # Returns
///
/// Returns `true` if the handler was found and removed, `false` otherwise.
pub fn remove_output_handler(&mut self, id: usize, _of_type: SCStreamOutputType) -> bool {
// Mutex poisoning is unrecoverable; unwrap is appropriate
let mut registry = HANDLER_REGISTRY.lock().unwrap();
registry
.as_mut()
.and_then(|handlers| handlers.remove(&id))
.is_some()
pub fn remove_output_handler(&mut self, id: usize, of_type: SCStreamOutputType) -> bool {
// Remove from our tracking
let pos = self.handler_ids.iter().position(|(hid, _)| *hid == id);
if pos.is_none() {
return false;
}
self.handler_ids.remove(pos.unwrap());

// Remove from global registry
{
let mut registry = HANDLER_REGISTRY.lock().unwrap();
if let Some(handlers) = registry.as_mut() {
handlers.remove(&id);
}
}

// Tell Swift to remove the output
let output_type_int = match of_type {
SCStreamOutputType::Screen => 0,
SCStreamOutputType::Audio => 1,
SCStreamOutputType::Microphone => 2,
};
unsafe { ffi::sc_stream_remove_stream_output(self.ptr, output_type_int) }
}

/// Start capturing screen content
Expand Down Expand Up @@ -502,6 +530,25 @@ impl SCStream {

impl Drop for SCStream {
fn drop(&mut self) {
// Clean up all registered handlers
for (id, of_type) in std::mem::take(&mut self.handler_ids) {
// Remove from global registry
{
let mut registry = HANDLER_REGISTRY.lock().unwrap();
if let Some(handlers) = registry.as_mut() {
handlers.remove(&id);
}
}

// Tell Swift to remove the output
let output_type_int = match of_type {
SCStreamOutputType::Screen => 0,
SCStreamOutputType::Audio => 1,
SCStreamOutputType::Microphone => 2,
};
unsafe { ffi::sc_stream_remove_stream_output(self.ptr, output_type_int) };
}

if !self.ptr.is_null() {
unsafe { ffi::sc_stream_release(self.ptr) };
}
Expand All @@ -513,14 +560,19 @@ impl Clone for SCStream {
unsafe {
Self {
ptr: crate::ffi::sc_stream_retain(self.ptr),
// Cloned stream starts with no handlers - handlers are per-instance
handler_ids: Vec::new(),
}
}
}
}

impl fmt::Debug for SCStream {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SCStream").field("ptr", &self.ptr).finish()
f.debug_struct("SCStream")
.field("ptr", &self.ptr)
.field("handler_ids", &self.handler_ids)
.finish()
}
}

Expand Down
45 changes: 45 additions & 0 deletions swift-bridge/Sources/CoreMedia/CoreMedia.swift
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,51 @@ public func cm_format_description_release(_ formatDescription: UnsafeMutableRawP
Unmanaged<CMFormatDescription>.fromOpaque(formatDescription).release()
}

@_cdecl("cm_format_description_get_audio_sample_rate")
public func cm_format_description_get_audio_sample_rate(_ formatDescription: UnsafeMutableRawPointer) -> Double {
let desc = Unmanaged<CMFormatDescription>.fromOpaque(formatDescription).takeUnretainedValue()
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) else {
return 0.0
}
return asbd.pointee.mSampleRate
}

@_cdecl("cm_format_description_get_audio_channel_count")
public func cm_format_description_get_audio_channel_count(_ formatDescription: UnsafeMutableRawPointer) -> UInt32 {
let desc = Unmanaged<CMFormatDescription>.fromOpaque(formatDescription).takeUnretainedValue()
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) else {
return 0
}
return asbd.pointee.mChannelsPerFrame
}

@_cdecl("cm_format_description_get_audio_bits_per_channel")
public func cm_format_description_get_audio_bits_per_channel(_ formatDescription: UnsafeMutableRawPointer) -> UInt32 {
let desc = Unmanaged<CMFormatDescription>.fromOpaque(formatDescription).takeUnretainedValue()
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) else {
return 0
}
return asbd.pointee.mBitsPerChannel
}

@_cdecl("cm_format_description_get_audio_bytes_per_frame")
public func cm_format_description_get_audio_bytes_per_frame(_ formatDescription: UnsafeMutableRawPointer) -> UInt32 {
let desc = Unmanaged<CMFormatDescription>.fromOpaque(formatDescription).takeUnretainedValue()
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) else {
return 0
}
return asbd.pointee.mBytesPerFrame
}

@_cdecl("cm_format_description_get_audio_format_flags")
public func cm_format_description_get_audio_format_flags(_ formatDescription: UnsafeMutableRawPointer) -> UInt32 {
let desc = Unmanaged<CMFormatDescription>.fromOpaque(formatDescription).takeUnretainedValue()
guard let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) else {
return 0
}
return asbd.pointee.mFormatFlags
}

// MARK: - CMSampleBuffer Creation

@_cdecl("cm_sample_buffer_create_for_image_buffer")
Expand Down
Loading