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