Skip to content

Commit 2e58d1f

Browse files
committed
feat(screenshot): add multi-format image saving support
- Add ImageFormat enum with PNG, JPEG, TIFF, GIF, BMP, and HEIC variants - Add CGImage::save() method accepting format and quality parameters - Update save_png() to delegate to save() for consistency - Add cgimage_save_to_file FFI function with format and quality support refactor(tests): move inline tests to tests/ directory - Move audio_devices tests to tests/audio_devices_tests.rs - Move ffi_string tests to tests/ffi_string_tests.rs - Move sync_completion tests to tests/sync_completion_tests.rs
1 parent 4ac4814 commit 2e58d1f

9 files changed

Lines changed: 328 additions & 147 deletions

File tree

src/audio_devices.rs

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -93,32 +93,4 @@ impl AudioInputDevice {
9393
}
9494
}
9595

96-
#[cfg(test)]
97-
mod tests {
98-
use super::*;
9996

100-
#[test]
101-
fn test_list_audio_devices() {
102-
// Should not panic
103-
let devices = AudioInputDevice::list();
104-
// On most Macs, there should be at least one built-in microphone
105-
println!("Found {} audio input devices", devices.len());
106-
for device in &devices {
107-
println!(
108-
" {} - {} (default: {})",
109-
device.id, device.name, device.is_default
110-
);
111-
}
112-
}
113-
114-
#[test]
115-
fn test_default_device() {
116-
// Should not panic
117-
if let Some(device) = AudioInputDevice::default_device() {
118-
println!("Default device: {} - {}", device.id, device.name);
119-
assert!(device.is_default);
120-
} else {
121-
println!("No default audio input device");
122-
}
123-
}
124-
}

src/ffi/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,7 @@ extern "C" {
479479
sample_buffer_callback: extern "C" fn(*const c_void, *const c_void, i32),
480480
dispatch_queue: *const c_void,
481481
) -> bool;
482+
pub fn sc_stream_remove_stream_output(stream: *const c_void, output_type: i32) -> bool;
482483
pub fn sc_stream_start_capture(
483484
stream: *const c_void,
484485
context: *mut c_void,
@@ -684,6 +685,12 @@ extern "C" {
684685
pub fn cgimage_free_data(ptr: *mut u8);
685686
pub fn cgimage_release(image: *const c_void);
686687
pub fn cgimage_save_png(image: *const c_void, path: *const i8) -> bool;
688+
pub fn cgimage_save_to_file(
689+
image: *const c_void,
690+
path: *const i8,
691+
format: i32,
692+
quality: f32,
693+
) -> bool;
687694
}
688695

689696
// MARK: - SCScreenshotConfiguration (macOS 26.0+)

src/screenshot_manager.rs

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,71 @@ use std::ffi::c_void;
1212
#[cfg(feature = "macos_15_2")]
1313
use crate::cg::CGRect;
1414

15+
/// Image output format for saving screenshots
16+
///
17+
/// # Examples
18+
///
19+
/// ```no_run
20+
/// use screencapturekit::screenshot_manager::ImageFormat;
21+
///
22+
/// // PNG for lossless quality
23+
/// let format = ImageFormat::Png;
24+
///
25+
/// // JPEG with 80% quality
26+
/// let format = ImageFormat::Jpeg(0.8);
27+
///
28+
/// // HEIC with 90% quality (smaller file size than JPEG)
29+
/// let format = ImageFormat::Heic(0.9);
30+
/// ```
31+
#[derive(Debug, Clone, Copy, PartialEq)]
32+
pub enum ImageFormat {
33+
/// PNG format (lossless)
34+
Png,
35+
/// JPEG format with quality (0.0-1.0)
36+
Jpeg(f32),
37+
/// TIFF format (lossless)
38+
Tiff,
39+
/// GIF format
40+
Gif,
41+
/// BMP format
42+
Bmp,
43+
/// HEIC format with quality (0.0-1.0) - efficient compression
44+
Heic(f32),
45+
}
46+
47+
impl ImageFormat {
48+
fn to_format_id(self) -> i32 {
49+
match self {
50+
Self::Png => 0,
51+
Self::Jpeg(_) => 1,
52+
Self::Tiff => 2,
53+
Self::Gif => 3,
54+
Self::Bmp => 4,
55+
Self::Heic(_) => 5,
56+
}
57+
}
58+
59+
fn quality(self) -> f32 {
60+
match self {
61+
Self::Jpeg(q) | Self::Heic(q) => q.clamp(0.0, 1.0),
62+
_ => 1.0,
63+
}
64+
}
65+
66+
/// Get the typical file extension for this format
67+
#[must_use]
68+
pub const fn extension(&self) -> &'static str {
69+
match self {
70+
Self::Png => "png",
71+
Self::Jpeg(_) => "jpg",
72+
Self::Tiff => "tiff",
73+
Self::Gif => "gif",
74+
Self::Bmp => "bmp",
75+
Self::Heic(_) => "heic",
76+
}
77+
}
78+
}
79+
1580
extern "C" fn image_callback(
1681
image_ptr: *const c_void,
1782
error_ptr: *const i8,
@@ -199,15 +264,62 @@ impl CGImage {
199264
/// # }
200265
/// ```
201266
pub fn save_png(&self, path: &str) -> Result<(), SCError> {
267+
self.save(path, ImageFormat::Png)
268+
}
269+
270+
/// Save the image to a file in the specified format
271+
///
272+
/// # Arguments
273+
/// * `path` - The file path to save the image to
274+
/// * `format` - The output format (PNG, JPEG, TIFF, GIF, BMP, or HEIC)
275+
///
276+
/// # Errors
277+
/// Returns an error if the image cannot be saved
278+
///
279+
/// # Examples
280+
///
281+
/// ```no_run
282+
/// # use screencapturekit::screenshot_manager::{SCScreenshotManager, ImageFormat};
283+
/// # use screencapturekit::stream::{content_filter::SCContentFilter, configuration::SCStreamConfiguration};
284+
/// # use screencapturekit::shareable_content::SCShareableContent;
285+
/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
286+
/// # let content = SCShareableContent::get()?;
287+
/// # let display = &content.displays()[0];
288+
/// # let filter = SCContentFilter::builder().display(display).exclude_windows(&[]).build();
289+
/// # let config = SCStreamConfiguration::new().with_width(1920).with_height(1080);
290+
/// let image = SCScreenshotManager::capture_image(&filter, &config)?;
291+
///
292+
/// // Save as PNG (lossless)
293+
/// image.save("/tmp/screenshot.png", ImageFormat::Png)?;
294+
///
295+
/// // Save as JPEG with 85% quality
296+
/// image.save("/tmp/screenshot.jpg", ImageFormat::Jpeg(0.85))?;
297+
///
298+
/// // Save as HEIC with 90% quality (smaller file size)
299+
/// image.save("/tmp/screenshot.heic", ImageFormat::Heic(0.9))?;
300+
/// # Ok(())
301+
/// # }
302+
/// ```
303+
pub fn save(&self, path: &str, format: ImageFormat) -> Result<(), SCError> {
202304
let c_path = std::ffi::CString::new(path)
203305
.map_err(|_| SCError::internal_error("Path contains null bytes"))?;
204306

205-
let success = unsafe { crate::ffi::cgimage_save_png(self.ptr, c_path.as_ptr()) };
307+
let success = unsafe {
308+
crate::ffi::cgimage_save_to_file(
309+
self.ptr,
310+
c_path.as_ptr(),
311+
format.to_format_id(),
312+
format.quality(),
313+
)
314+
};
206315

207316
if success {
208317
Ok(())
209318
} else {
210-
Err(SCError::internal_error("Failed to save image as PNG"))
319+
Err(SCError::internal_error(format!(
320+
"Failed to save image as {}",
321+
format.extension().to_uppercase()
322+
)))
211323
}
212324
}
213325
}

src/utils/ffi_string.rs

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -128,42 +128,4 @@ where
128128
ffi_string_owned(ffi_call).unwrap_or_default()
129129
}
130130

131-
#[cfg(test)]
132-
mod tests {
133-
use super::*;
134131

135-
#[test]
136-
fn test_ffi_string_from_buffer_success() {
137-
let result = unsafe {
138-
ffi_string_from_buffer(64, |buf, _len| {
139-
let test_str = b"hello\0";
140-
std::ptr::copy_nonoverlapping(test_str.as_ptr(), buf.cast::<u8>(), test_str.len());
141-
true
142-
})
143-
};
144-
assert_eq!(result, Some("hello".to_string()));
145-
}
146-
147-
#[test]
148-
fn test_ffi_string_from_buffer_failure() {
149-
let result = unsafe { ffi_string_from_buffer(64, |_buf, _len| false) };
150-
assert_eq!(result, None);
151-
}
152-
153-
#[test]
154-
fn test_ffi_string_from_buffer_empty() {
155-
let result = unsafe {
156-
ffi_string_from_buffer(64, |buf, _len| {
157-
*buf = 0; // empty string
158-
true
159-
})
160-
};
161-
assert_eq!(result, None);
162-
}
163-
164-
#[test]
165-
fn test_ffi_string_or_empty() {
166-
let result = unsafe { ffi_string_from_buffer_or_empty(64, |_buf, _len| false) };
167-
assert_eq!(result, String::new());
168-
}
169-
}

src/utils/sync_completion.rs

Lines changed: 0 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -284,83 +284,4 @@ impl UnitCompletion {
284284
}
285285
}
286286

287-
#[cfg(test)]
288-
mod tests {
289-
use super::*;
290287

291-
#[test]
292-
fn test_sync_completion_success() {
293-
let (completion, context) = SyncCompletion::<i32>::new();
294-
295-
// Simulate callback being called (normally from FFI)
296-
unsafe { SyncCompletion::complete_ok(context, 42) };
297-
298-
let result = completion.wait();
299-
assert_eq!(result, Ok(42));
300-
}
301-
302-
#[test]
303-
fn test_sync_completion_error() {
304-
let (completion, context) = SyncCompletion::<i32>::new();
305-
306-
// Simulate callback being called with error
307-
unsafe { SyncCompletion::<i32>::complete_err(context, "test error".to_string()) };
308-
309-
let result = completion.wait();
310-
assert_eq!(result, Err("test error".to_string()));
311-
}
312-
313-
#[test]
314-
fn test_unit_completion_callback_success() {
315-
let (completion, context) = UnitCompletion::new();
316-
317-
// Simulate successful callback
318-
UnitCompletion::callback(context, true, std::ptr::null());
319-
320-
let result = completion.wait();
321-
assert!(result.is_ok());
322-
}
323-
324-
#[test]
325-
fn test_unit_completion_callback_error() {
326-
let (completion, context) = UnitCompletion::new();
327-
let error_msg = std::ffi::CString::new("test error").unwrap();
328-
329-
// Simulate error callback
330-
UnitCompletion::callback(context, false, error_msg.as_ptr());
331-
332-
let result = completion.wait();
333-
assert_eq!(result, Err("test error".to_string()));
334-
}
335-
336-
#[test]
337-
fn test_error_from_cstr_null() {
338-
let result = unsafe { error_from_cstr(std::ptr::null()) };
339-
assert_eq!(result, "Unknown error");
340-
}
341-
342-
#[test]
343-
fn test_error_from_cstr_valid() {
344-
let msg = std::ffi::CString::new("hello").unwrap();
345-
let result = unsafe { error_from_cstr(msg.as_ptr()) };
346-
assert_eq!(result, "hello");
347-
}
348-
349-
#[test]
350-
fn test_async_completion_immediate() {
351-
let (future, context) = AsyncCompletion::<i32>::create();
352-
353-
// Complete immediately before polling
354-
unsafe { AsyncCompletion::complete_ok(context, 42) };
355-
356-
// Poll should return Ready immediately
357-
let waker = std::task::Waker::noop();
358-
let mut cx = Context::from_waker(waker);
359-
let mut pinned = std::pin::pin!(future);
360-
361-
match pinned.as_mut().poll(&mut cx) {
362-
Poll::Ready(Ok(v)) => assert_eq!(v, 42),
363-
_ => panic!("Expected Ready(Ok(42))"),
364-
}
365-
}
366-
}

swift-bridge/Sources/CoreGraphics/CoreGraphics.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,40 @@ public func saveCGImageToPNG(_ image: OpaquePointer, _ pathPtr: UnsafePointer<CC
158158
return CGImageDestinationFinalize(destination)
159159
}
160160

161+
/// Save CGImage to file with specified format
162+
/// format: 0=PNG, 1=JPEG, 2=TIFF, 3=GIF, 4=BMP, 5=HEIC
163+
/// quality: 0.0-1.0 for lossy formats (JPEG, HEIC)
164+
@_cdecl("cgimage_save_to_file")
165+
public func saveCGImageToFile(_ image: OpaquePointer, _ pathPtr: UnsafePointer<CChar>, _ format: Int32, _ quality: Float) -> Bool {
166+
let cgImage = Unmanaged<CGImage>.fromOpaque(UnsafeRawPointer(image)).takeUnretainedValue()
167+
let path = String(cString: pathPtr)
168+
let url = URL(fileURLWithPath: path)
169+
170+
let utType: UTType
171+
switch format {
172+
case 0: utType = .png
173+
case 1: utType = .jpeg
174+
case 2: utType = .tiff
175+
case 3: utType = .gif
176+
case 4: utType = .bmp
177+
case 5: utType = .heic
178+
default: return false
179+
}
180+
181+
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, utType.identifier as CFString, 1, nil) else {
182+
return false
183+
}
184+
185+
// Set quality for lossy formats
186+
var properties: [CFString: Any]? = nil
187+
if format == 1 || format == 5 { // JPEG or HEIC
188+
properties = [kCGImageDestinationLossyCompressionQuality: quality]
189+
}
190+
191+
CGImageDestinationAddImage(destination, cgImage, properties as CFDictionary?)
192+
return CGImageDestinationFinalize(destination)
193+
}
194+
161195
@_cdecl("cgimage_hash")
162196
public func cgimageHash(_ image: OpaquePointer) -> Int {
163197
let cgImage = Unmanaged<CGImage>.fromOpaque(UnsafeRawPointer(image)).takeUnretainedValue()

tests/audio_devices_tests.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
//! Tests for audio input device enumeration
2+
3+
use screencapturekit::audio_devices::AudioInputDevice;
4+
5+
#[test]
6+
fn test_list_audio_devices() {
7+
// Should not panic
8+
let devices = AudioInputDevice::list();
9+
// On most Macs, there should be at least one built-in microphone
10+
println!("Found {} audio input devices", devices.len());
11+
for device in &devices {
12+
println!(
13+
" {} - {} (default: {})",
14+
device.id, device.name, device.is_default
15+
);
16+
}
17+
}
18+
19+
#[test]
20+
fn test_default_device() {
21+
// Should not panic
22+
if let Some(device) = AudioInputDevice::default_device() {
23+
println!("Default device: {} - {}", device.id, device.name);
24+
assert!(device.is_default);
25+
} else {
26+
println!("No default audio input device");
27+
}
28+
}

0 commit comments

Comments
 (0)