1#[cfg(target_os = "macos")]
2use anyhow::Context;
3use anyhow::{Result, ensure};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ImageInfo {
8 pub decoder_type: String,
9 pub width: usize,
10 pub height: usize,
11 pub frames: usize,
12 pub bits_per_component: usize,
13 pub orientation: i64,
14 pub transparent_pixels: usize,
15 pub has_transparent_pixels: bool,
16}
17
18pub(crate) struct Decoded {
19 pub info: ImageInfo,
20 pub pixels: Vec<f32>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ImageDifference {
26 pub rgb_mae_255: f64,
27 pub psnr_db: Option<f64>,
28 pub max_alpha_error: f32,
29}
30
31pub(crate) fn compare(a: &Decoded, b: &Decoded) -> Result<ImageDifference> {
32 ensure!(
33 a.info.width == b.info.width && a.info.height == b.info.height,
34 "dimensions_changed"
35 );
36 ensure!(
37 a.info.orientation == b.info.orientation,
38 "orientation_changed"
39 );
40 ensure!(
41 a.info.frames == 1 && b.info.frames == 1 && !a.pixels.is_empty(),
42 "multiple_frames"
43 );
44 ensure!(a.pixels.len() == b.pixels.len(), "pixel_buffer_mismatch");
45 let mut absolute = 0.0_f64;
46 let mut squared = 0.0_f64;
47 let mut alpha = 0.0_f32;
48 for (a, b) in a
49 .pixels
50 .as_chunks::<4>()
51 .0
52 .iter()
53 .zip(b.pixels.as_chunks::<4>().0.iter())
54 {
55 for channel in 0..3 {
56 let delta = f64::from(a[channel] - b[channel]);
57 absolute += delta.abs();
58 squared += delta * delta;
59 }
60 alpha = alpha.max((a[3] - b[3]).abs());
61 }
62 let channels = (a.pixels.len() / 4 * 3) as f64;
63 let mse = squared / channels;
64 Ok(ImageDifference {
65 rgb_mae_255: absolute / channels * 255.0,
66 psnr_db: (mse > 0.0).then(|| -10.0 * mse.log10()),
67 max_alpha_error: alpha,
68 })
69}
70
71pub(crate) fn preview(image: &Decoded) -> Result<Vec<u8>> {
72 let (display_width, display_height) = if (5..=8).contains(&image.info.orientation) {
73 (image.info.height, image.info.width)
74 } else {
75 (image.info.width, image.info.height)
76 };
77 let scale = (256.0 / display_width.max(display_height) as f64).min(1.0);
78 let width = (display_width as f64 * scale).round().max(1.0) as usize;
79 let height = (display_height as f64 * scale).round().max(1.0) as usize;
80 let mut bytes = Vec::with_capacity(width * height * 4);
81 for y in 0..height {
82 for x in 0..width {
83 let dx = x * display_width / width;
84 let dy = y * display_height / height;
85 let w = image.info.width;
86 let h = image.info.height;
87 let (sx, sy) = match image.info.orientation {
88 2 => (w - 1 - dx, dy),
89 3 => (w - 1 - dx, h - 1 - dy),
90 4 => (dx, h - 1 - dy),
91 5 => (dy, dx),
92 6 => (dy, h - 1 - dx),
93 7 => (w - 1 - dy, h - 1 - dx),
94 8 => (w - 1 - dy, dx),
95 _ => (dx, dy),
96 };
97 let index = (sy * image.info.width + sx) * 4;
98 let pixel = &image.pixels[index..index + 4];
99 let alpha = pixel[3].clamp(0.0, 1.0);
100 for value in &pixel[..3] {
101 bytes.push(if alpha == 0.0 {
102 0
103 } else {
104 (value / alpha * 255.0).round().clamp(0.0, 255.0) as u8
105 });
106 }
107 bytes.push((alpha * 255.0).round() as u8);
108 }
109 }
110 let mut output = Vec::new();
111 {
112 let mut encoder = png::Encoder::new(&mut output, width as u32, height as u32);
113 encoder.set_color(png::ColorType::Rgba);
114 encoder.set_depth(png::BitDepth::Eight);
115 encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
116 encoder.write_header()?.write_image_data(&bytes)?;
117 }
118 Ok(output)
119}
120
121pub fn image_backend_available() -> bool {
122 cfg!(target_os = "macos")
123}
124
125pub(crate) fn check_encoders() -> Result<()> {
126 let mut bytes = Vec::new();
127 {
128 let mut encoder = png::Encoder::new(&mut bytes, 64, 64);
129 encoder.set_color(png::ColorType::Rgb);
130 encoder.set_depth(png::BitDepth::Eight);
131 encoder
132 .write_header()?
133 .write_image_data(&vec![128; 64 * 64 * 3])?;
134 }
135 for format in ["jpeg", "heic"] {
136 let encoded = encode(&bytes, format, 85).map_err(|error| anyhow::anyhow!("{format} encoder unavailable: {error}; image analysis requires macOS ImageIO access (restrictive sandboxes can block HEIC); use --probe-only for detection without encoding"))?;
137 decode(&encoded)?;
138 }
139 Ok(())
140}
141
142#[cfg(not(target_os = "macos"))]
143pub(crate) fn decode(_: &[u8]) -> Result<Decoded> {
144 anyhow::bail!("image_analysis_requires_macos_imageio")
145}
146#[cfg(not(target_os = "macos"))]
147pub(crate) fn encode(_: &[u8], _: &str, _: u8) -> Result<Vec<u8>> {
148 anyhow::bail!("image_encoding_requires_macos_imageio")
149}
150
151#[cfg(target_os = "macos")]
152mod apple {
153 use super::*;
154 use objc2_core_foundation::{
155 CFData, CFDictionary, CFMutableData, CFNumber, CFRetained, CFString, CFType, CGPoint,
156 CGRect, CGSize,
157 };
158 use objc2_core_graphics::{
159 CGBitmapContextCreate, CGColorSpace, CGContext, CGImage, kCGColorSpaceSRGB,
160 };
161 use objc2_image_io::{
162 CGImageDestination, CGImageSource, kCGImageDestinationLossyCompressionQuality,
163 kCGImagePropertyOrientation,
164 };
165
166 fn source(bytes: &[u8]) -> Result<CFRetained<CGImageSource>> {
167 ensure!(bytes.len() <= 64 * 1024 * 1024, "input_exceeds_64_mib");
168 let data = CFData::from_bytes(bytes);
169 unsafe { CGImageSource::with_data(&data, None) }.context("imageio_cannot_read_image")
172 }
173
174 pub(crate) fn decode(bytes: &[u8]) -> Result<Decoded> {
175 let source = source(bytes)?;
176 let (frames, decoder_type, image, orientation) = unsafe {
178 let frames = source.count();
179 ensure!(frames > 0, "image_has_no_frames");
180 let image = source
181 .image_at_index(0, None)
182 .context("imageio_decode_failed")?;
183 let properties = source.properties_at_index(0, None);
184 let orientation = properties
185 .as_ref()
186 .and_then(|dictionary| {
187 dictionary
188 .cast_unchecked::<CFString, CFType>()
189 .get(kCGImagePropertyOrientation)
190 })
191 .and_then(|value| value.downcast_ref::<CFNumber>().and_then(CFNumber::as_i64))
192 .unwrap_or(1);
193 (
194 frames,
195 source
196 .r#type()
197 .map(|value| value.to_string())
198 .unwrap_or_default(),
199 image,
200 orientation,
201 )
202 };
203 let width = CGImage::width(Some(&image));
204 let height = CGImage::height(Some(&image));
205 let length = width
206 .checked_mul(height)
207 .and_then(|n| n.checked_mul(4))
208 .context("image_dimensions_overflow")?;
209 ensure!(
210 width > 0 && height > 0 && length <= 16 * 1024 * 1024,
211 "decoded_image_exceeds_64_mib_float_buffer"
212 );
213 let mut pixels = vec![0_f32; length];
214 let space = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB }))
216 .context("srgb_unavailable")?;
217 let context = unsafe {
222 CGBitmapContextCreate(
223 pixels.as_mut_ptr().cast(),
224 width,
225 height,
226 32,
227 width * 16,
228 Some(&space),
229 1 | (1 << 8) | (2 << 12),
230 )
231 }
232 .context("float_bitmap_context_unavailable")?;
233 CGContext::draw_image(
234 Some(&context),
235 CGRect {
236 origin: CGPoint { x: 0.0, y: 0.0 },
237 size: CGSize {
238 width: width as f64,
239 height: height as f64,
240 },
241 },
242 Some(&image),
243 );
244 drop(context);
245 ensure!(
246 pixels.iter().all(|v| v.is_finite()),
247 "non_finite_decoded_samples"
248 );
249 let transparent_pixels = pixels
250 .as_chunks::<4>()
251 .0
252 .iter()
253 .filter(|pixel| pixel[3] < 1.0)
254 .count();
255 Ok(Decoded {
256 info: ImageInfo {
257 decoder_type,
258 width,
259 height,
260 frames,
261 bits_per_component: CGImage::bits_per_component(Some(&image)),
262 orientation,
263 transparent_pixels,
264 has_transparent_pixels: transparent_pixels > 0,
265 },
266 pixels,
267 })
268 }
269
270 pub(crate) fn encode(bytes: &[u8], format: &str, quality: u8) -> Result<Vec<u8>> {
271 ensure!(
272 matches!(format, "jpeg" | "heic") && (1..=100).contains(&quality),
273 "invalid_encoding_options"
274 );
275 let source = source(bytes)?;
276 unsafe {
280 ensure!(source.count() == 1, "multiple_frames_not_transcoded");
281 let output = CFMutableData::new(None, 0).context("cannot_allocate_encoded_buffer")?;
282 let type_id = CFString::from_str(if format == "jpeg" {
283 "public.jpeg"
284 } else {
285 "public.heic"
286 });
287 let destination = CGImageDestination::with_data(&output, &type_id, 1, None)
288 .context("requested_encoder_unavailable")?;
289 let quality = CFNumber::new_f64(f64::from(quality) / 100.0);
290 let options = CFDictionary::<CFString, CFType>::from_slices(
291 &[kCGImageDestinationLossyCompressionQuality],
292 &[quality.as_ref()],
293 );
294 destination.add_image_from_source(&source, 0, Some(options.as_opaque()));
295 ensure!(destination.finalize(), "imageio_encoding_failed");
296 drop(destination);
297 Ok(output.to_vec())
298 }
299 }
300}
301
302#[cfg(target_os = "macos")]
303pub(crate) use apple::{decode, encode};
304
305#[cfg(all(test, target_os = "macos"))]
306mod tests {
307 use super::*;
308
309 fn png(alpha: u8) -> Vec<u8> {
310 let mut bytes = Vec::new();
311 {
312 let mut encoder = png::Encoder::new(&mut bytes, 128, 128);
313 encoder.set_color(png::ColorType::Rgba);
314 encoder.set_depth(png::BitDepth::Eight);
315 let mut data = Vec::new();
316 for i in 0..128 * 128 {
317 data.extend_from_slice(&[
318 (i % 256) as u8,
319 100,
320 75,
321 if i % 2 == 0 { alpha } else { 255 },
322 ]);
323 }
324 encoder
325 .write_header()
326 .unwrap()
327 .write_image_data(&data)
328 .unwrap();
329 }
330 bytes
331 }
332
333 #[test]
334 fn opaque_alpha_channel_is_not_transparency() {
335 let decoded = decode(&png(255)).unwrap();
336 assert!(!decoded.info.has_transparent_pixels);
337 assert_eq!(decoded.info.transparent_pixels, 0);
338 }
339
340 #[test]
341 fn sixteen_bit_near_opaque_alpha_is_still_transparency() {
342 let mut bytes = Vec::new();
343 {
344 let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
345 encoder.set_color(png::ColorType::Rgba);
346 encoder.set_depth(png::BitDepth::Sixteen);
347 encoder
348 .write_header()
349 .unwrap()
350 .write_image_data(&[0, 0, 0, 0, 0, 0, 255, 254])
351 .unwrap();
352 }
353 let image = decode(&bytes).unwrap();
354 assert!(image.info.has_transparent_pixels);
355 assert_eq!(image.info.transparent_pixels, 1);
356 }
357
358 #[test]
359 fn transparent_heic_retains_alpha_and_can_be_decoded() {
360 let bytes = png(128);
361 let original = decode(&bytes).unwrap();
362 assert_eq!(original.info.transparent_pixels, 8192);
363 let heic = encode(&bytes, "heic", 85).unwrap();
364 let decoded = decode(&heic).unwrap();
365 assert!(decoded.info.decoder_type.contains("heic"));
366 assert!(decoded.info.has_transparent_pixels);
367 let difference = compare(&original, &decoded).unwrap();
368 assert!(
369 difference.max_alpha_error <= 1.0 / 255.0 + 0.000001,
370 "{difference:?}"
371 );
372 }
373
374 #[test]
375 fn opaque_image_can_compare_jpeg_and_heic() {
376 let bytes = png(255);
377 let original = decode(&bytes).unwrap();
378 for format in ["jpeg", "heic"] {
379 let encoded = encode(&bytes, format, 75).unwrap();
380 let decoded = decode(&encoded).unwrap();
381 let difference = compare(&original, &decoded).unwrap();
382 assert!(difference.rgb_mae_255.is_finite());
383 assert!(difference.max_alpha_error <= 0.000001);
384 }
385 }
386}