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