Skip to main content

falcon_library/
imageio.rs

1//! Pictures macOS decodes through ImageIO: AVIF, HEIC and HEIF, JPEG XL
2//! (macOS 14 and later) and their animated forms (AVIF and HEIF
3//! sequences). The `image` crate reads none of them, so on macOS these
4//! decode here at full resolution, turned by their orientation, and
5//! animated files hand out their frames and delays. Elsewhere every call
6//! fails and callers fall back to QuickLook or a placeholder.
7//!
8//! Which formats the running system reads is asked of ImageIO
9//! (`CGImageSourceCopyTypeIdentifiers`), so JPEG XL on macOS 13 reports
10//! itself unsupported instead of failing every decode.
11
12use image::metadata::Orientation;
13use image::{DynamicImage, RgbaImage};
14use std::path::Path;
15
16use crate::thumbnail::ThumbError;
17
18/// The extensions this module may decode, with the type ImageIO knows
19/// each as.
20const TYPES: [(&str, &str); 7] = [
21    ("avif", "public.avif"),
22    ("avifs", "public.avis"),
23    ("heic", "public.heic"),
24    ("hif", "public.heic"),
25    ("heif", "public.heif"),
26    ("heics", "public.heics"),
27    ("jxl", "public.jpeg-xl"),
28];
29
30/// The ImageIO type of a (lower-case) extension this module handles.
31pub fn type_of(ext: &str) -> Option<&'static str> {
32    TYPES.iter().find(|(e, _)| *e == ext).map(|(_, t)| *t)
33}
34
35/// Whether `ext` is one of the formats this module decodes on a system that
36/// supports it (see [`available`] for whether this one does).
37pub fn handles(ext: &str) -> bool {
38    type_of(&ext.to_lowercase()).is_some()
39}
40
41/// Whether the running system's ImageIO reads `ext` (JPEG XL needs
42/// macOS 14). Always false off macOS.
43pub fn available(ext: &str) -> bool {
44    type_of(&ext.to_lowercase()).is_some_and(|t| readable_types().iter().any(|r| r == t))
45}
46
47/// The orientation an ImageIO `Orientation` property (the EXIF values 1–8)
48/// stands for; anything else leaves the picture as stored.
49pub fn orientation(value: i64) -> Orientation {
50    u8::try_from(value).ok().and_then(Orientation::from_exif).unwrap_or(Orientation::NoTransforms)
51}
52
53/// A frame's delay in milliseconds from its ImageIO properties: the
54/// unclamped delay when the file gives one, else the clamped one, in
55/// seconds. Browsers' rule for tiny delays is applied by the player.
56pub fn delay_ms(unclamped: Option<f64>, clamped: Option<f64>) -> u32 {
57    let secs = unclamped.filter(|s| s.is_finite() && *s > 0.0).or(clamped).unwrap_or(0.0);
58    if secs.is_finite() && secs > 0.0 { (secs * 1000.0).round().min(f64::from(u32::MAX)) as u32 } else { 0 }
59}
60
61/// Whether a file of `count` images is an animation: several images, the
62/// first carrying a frame delay. An image collection (a HEIF burst, or a
63/// HEIC with its depth map) has several images but no timing, and stays a
64/// still.
65pub fn is_animated(count: usize, first_has_delay: bool) -> bool {
66    count >= 2 && first_has_delay
67}
68
69/// The size a picture of `w` x `h` stored pixels shows at once turned.
70pub fn oriented_size(w: u32, h: u32, o: Orientation) -> (u32, u32) {
71    match o {
72        Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH => (h, w),
73        _ => (w, h),
74    }
75}
76
77/// Straight alpha from premultiplied RGBA, in place.
78fn unpremultiply(rgba: &mut [u8]) {
79    for px in rgba.as_chunks_mut::<4>().0 {
80        let a = px[3];
81        if a != 0 && a != 255 {
82            for c in &mut px[..3] {
83                *c = ((u32::from(*c) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8;
84            }
85        }
86    }
87}
88
89/// Decodes the file's main picture at full resolution, turned upright.
90pub fn decode(path: &Path) -> Result<DynamicImage, ThumbError> {
91    imp::decode(path)
92}
93
94/// An animated AVIF or HEIF sequence, read frame by frame. Each frame is
95/// a whole picture (sequence frames don't need compositing).
96pub struct Animation {
97    inner: imp::Source,
98    size: (u32, u32),
99    delays: Vec<u32>,
100}
101
102impl Animation {
103    /// Opens `path`; `Ok(None)` when it holds a single picture.
104    pub fn open(path: &Path) -> Result<Option<Animation>, String> {
105        let Some((inner, delays)) = imp::Source::open(path)? else { return Ok(None) };
106        let size = inner.size(0);
107        Ok(Some(Animation { inner, size, delays }))
108    }
109
110    /// The first frame's size, upright.
111    pub fn size(&self) -> (u32, u32) {
112        self.size
113    }
114
115    /// Each frame's delay in milliseconds.
116    pub fn delays(&self) -> &[u32] {
117        &self.delays
118    }
119
120    /// Frame `i`, upright.
121    pub fn frame(&mut self, i: usize) -> Result<RgbaImage, String> {
122        self.inner.frame(i)
123    }
124}
125
126/// One picture to write with [`write`].
127#[derive(Clone, Copy)]
128pub struct WriteFrame<'a> {
129    pub image: &'a RgbaImage,
130    /// The EXIF orientation to record (1 leaves it upright).
131    pub orientation: u8,
132    /// For a HEIF sequence: how long the frame shows, in seconds.
133    pub delay: Option<f64>,
134}
135
136/// Writes `frames` to `path` as the ImageIO format of `ext` (macOS can
137/// write AVIF, HEIC and HEIF sequences, not AVIF sequences or JPEG XL).
138/// Several frames with delays make a HEIF sequence.
139pub fn write(path: &Path, ext: &str, frames: &[WriteFrame]) -> Result<(), String> {
140    let uti = type_of(&ext.to_lowercase()).ok_or_else(|| format!("ImageIO doesn't handle .{ext}"))?;
141    imp::write(path, uti, frames)
142}
143
144/// Decodes the picture in `data` no larger than `max_side` pixels on its
145/// longer side, as straight-alpha sRGB RGBA, not turned. ImageIO scales
146/// while it decodes (a JPEG at a fraction of its size directly), so a huge
147/// picture never sits in memory at full size. Any format ImageIO reads.
148pub fn decode_bytes_within(data: &[u8], max_side: u32) -> Result<RgbaImage, String> {
149    imp::decode_bytes_within(data, max_side.max(1))
150}
151
152fn readable_types() -> &'static [String] {
153    static TYPES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
154    TYPES.get_or_init(imp::readable_types)
155}
156
157#[cfg(target_os = "macos")]
158mod imp {
159    //! Every call below follows the same two rules: receivers and
160    //! arguments are live `CFRetained`/`Retained` values, and the
161    //! dictionaries ImageIO returns are read through their toll-free
162    //! bridge to `NSDictionary` (a `CFDictionary` *is* an `NSDictionary`).
163
164    use super::{Orientation, Path, RgbaImage, ThumbError, delay_ms, is_animated, orientation, unpremultiply};
165    use image::DynamicImage;
166    use objc2::rc::Retained;
167    use objc2::runtime::AnyObject;
168    use objc2_core_foundation::{CFArray, CFData, CFDictionary, CFRetained, CFString, CFURL, CGPoint, CGRect, CGSize};
169    use objc2_core_graphics::{
170        CGBitmapContextCreate, CGBitmapContextCreateImage, CGColorSpace, CGContext, CGImage, CGImageAlphaInfo, CGImageByteOrderInfo,
171        kCGColorSpaceSRGB,
172    };
173    use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString};
174    use objc2_image_io::{CGImageDestination, CGImageSource};
175
176    fn as_ns(d: &CFDictionary) -> &NSDictionary<AnyObject, AnyObject> {
177        // SAFETY: CFDictionary and NSDictionary are toll-free bridged; the
178        // reference keeps the lifetime of `d`.
179        unsafe { &*(d as *const CFDictionary).cast::<NSDictionary<AnyObject, AnyObject>>() }
180    }
181
182    fn get(d: &NSDictionary<AnyObject, AnyObject>, key: &str) -> Option<Retained<AnyObject>> {
183        let key = NSString::from_str(key);
184        d.objectForKey(&key)
185    }
186
187    fn number(d: &NSDictionary<AnyObject, AnyObject>, key: &str) -> Option<f64> {
188        get(d, key)?.downcast::<NSNumber>().ok().map(|n| n.doubleValue())
189    }
190
191    /// The frame delay found in any of a frame's per-format dictionaries
192    /// (`{AVIS}`, `{HEICS}`, `{GIF}` and so on all use the same keys).
193    fn frame_delay(props: &NSDictionary<AnyObject, AnyObject>) -> Option<u32> {
194        let mut found = None;
195        for key in props.allKeys() {
196            let Some(value) = props.objectForKey(&key) else { continue };
197            let Ok(sub) = value.downcast::<NSDictionary>() else { continue };
198            // SAFETY: an NSDictionary's keys and values are objects.
199            let sub: &NSDictionary<AnyObject, AnyObject> = unsafe { &*(Retained::as_ptr(&sub)).cast() };
200            let (unclamped, clamped) = (number(sub, "UnclampedDelayTime"), number(sub, "DelayTime"));
201            if unclamped.is_some() || clamped.is_some() {
202                found = Some(delay_ms(unclamped, clamped));
203                break;
204            }
205        }
206        found
207    }
208
209    fn open_source(path: &Path) -> Result<CFRetained<CGImageSource>, String> {
210        let url = CFURL::from_file_path(path).ok_or("not a file path")?;
211        // SAFETY: owned; no options.
212        unsafe { CGImageSource::with_url(&url, None) }.ok_or_else(|| "ImageIO can't read the file".to_string())
213    }
214
215    fn properties(src: &CGImageSource, i: usize) -> Option<CFRetained<CFDictionary>> {
216        // SAFETY: owned; no options.
217        unsafe { src.properties_at_index(i, None) }
218    }
219
220    fn orientation_at(src: &CGImageSource, i: usize) -> Orientation {
221        properties(src, i).and_then(|p| number(as_ns(&p), "Orientation")).map_or(Orientation::NoTransforms, |v| orientation(v as i64))
222    }
223
224    /// Image `i` of `src` as straight-alpha RGBA in sRGB, upright.
225    fn decode_at(src: &CGImageSource, i: usize) -> Result<RgbaImage, String> {
226        // SAFETY: owned; no options.
227        let image = unsafe { src.image_at_index(i, None) }.ok_or("ImageIO decoded no picture")?;
228        let mut img = draw(&image)?;
229        let o = orientation_at(src, i);
230        if o != Orientation::NoTransforms {
231            let mut d = DynamicImage::ImageRgba8(img);
232            d.apply_orientation(o);
233            img = d.into_rgba8();
234        }
235        Ok(img)
236    }
237
238    /// Draws a CGImage into an RGBA buffer of its own size.
239    fn draw(image: &CGImage) -> Result<RgbaImage, String> {
240        let (w, h) = (CGImage::width(Some(image)), CGImage::height(Some(image)));
241        if w == 0 || h == 0 || w.saturating_mul(h) > 1 << 30 {
242            return Err(format!("a {w} x {h} picture can't be decoded"));
243        }
244        let mut rgba = vec![0u8; w * h * 4];
245        // SAFETY: kCGColorSpaceSRGB is a constant CFString.
246        let space = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB })).or_else(CGColorSpace::new_device_rgb).ok_or("no RGB colour space")?;
247        let info = CGImageAlphaInfo::PremultipliedLast.0 | CGImageByteOrderInfo::Order32Big.0;
248        // SAFETY: `rgba` is `w * h * 4` bytes and outlives the context,
249        // dropped below; 8 bits per component with `w * 4` bytes per row
250        // matches that buffer exactly.
251        let ctx = unsafe { CGBitmapContextCreate(rgba.as_mut_ptr().cast(), w, h, 8, w * 4, Some(&space), info) }.ok_or("no bitmap context")?;
252        CGContext::draw_image(Some(&ctx), CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(w as f64, h as f64)), Some(image));
253        drop(ctx);
254        unpremultiply(&mut rgba);
255        RgbaImage::from_raw(w as u32, h as u32, rgba).ok_or_else(|| "bad buffer".to_string())
256    }
257
258    pub fn decode(path: &Path) -> Result<DynamicImage, ThumbError> {
259        let src = open_source(path).map_err(ThumbError)?;
260        // SAFETY: owned.
261        let primary = unsafe { src.primary_image_index() };
262        decode_at(&src, primary).map(DynamicImage::ImageRgba8).map_err(ThumbError)
263    }
264
265    pub fn decode_bytes_within(data: &[u8], max_side: u32) -> Result<RgbaImage, String> {
266        let cf = CFData::from_bytes(data);
267        // SAFETY: owned; no options.
268        let src = unsafe { CGImageSource::with_data(&cf, None) }.ok_or("ImageIO can't read the data")?;
269        // SAFETY: the keys are constant CFStrings.
270        let (always, max) = unsafe { (objc2_image_io::kCGImageSourceCreateThumbnailFromImageAlways, objc2_image_io::kCGImageSourceThumbnailMaxPixelSize) };
271        let opts = dict(vec![(&always.to_string(), obj(NSNumber::new_bool(true))), (&max.to_string(), obj(NSNumber::new_u32(max_side)))]);
272        // SAFETY: owned.
273        let primary = unsafe { src.primary_image_index() };
274        // SAFETY: NSDictionary is toll-free bridged to CFDictionary; its
275        // keys are strings and its values numbers, as ImageIO expects.
276        let image = unsafe { src.thumbnail_at_index(primary, Some(&*Retained::as_ptr(&opts).cast::<CFDictionary>())) }.ok_or("ImageIO decoded no picture")?;
277        draw(&image)
278    }
279
280    pub fn readable_types() -> Vec<String> {
281        // SAFETY: no arguments; returns a retained CFArray of CFStrings.
282        let types: CFRetained<CFArray> = unsafe { CGImageSource::type_identifiers() };
283        // SAFETY: toll-free bridged, holding only strings.
284        let types: &NSArray<NSString> = unsafe { &*(CFRetained::as_ptr(&types).as_ptr() as *const NSArray<NSString>) };
285        types.iter().map(|s| s.to_string()).collect()
286    }
287
288    /// Any object as an `AnyObject`, to sit among a dictionary's values.
289    fn obj<T: objc2::Message>(r: Retained<T>) -> Retained<AnyObject> {
290        // SAFETY: every Objective-C object is an AnyObject.
291        unsafe { Retained::cast_unchecked(r) }
292    }
293
294    fn dict(pairs: Vec<(&str, Retained<AnyObject>)>) -> Retained<NSDictionary<NSString, AnyObject>> {
295        let keys: Vec<Retained<NSString>> = pairs.iter().map(|(k, _)| NSString::from_str(k)).collect();
296        let keys: Vec<&NSString> = keys.iter().map(|k| &**k).collect();
297        let values: Vec<&AnyObject> = pairs.iter().map(|(_, v)| &**v).collect();
298        NSDictionary::from_slices(&keys, &values)
299    }
300
301    /// A CGImage holding a copy of `img`.
302    fn cg_image(img: &RgbaImage) -> Result<CFRetained<CGImage>, String> {
303        let (w, h) = (img.width() as usize, img.height() as usize);
304        let mut px = img.as_raw().clone();
305        // Premultiply, as the bitmap context stores it.
306        for p in px.as_chunks_mut::<4>().0 {
307            let a = u32::from(p[3]);
308            for c in &mut p[..3] {
309                *c = ((u32::from(*c) * a + 127) / 255) as u8;
310            }
311        }
312        // SAFETY: kCGColorSpaceSRGB is a constant CFString.
313        let space = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB })).ok_or("no sRGB colour space")?;
314        let info = CGImageAlphaInfo::PremultipliedLast.0 | CGImageByteOrderInfo::Order32Big.0;
315        // SAFETY: `px` is `w * h * 4` bytes and outlives the context; the
316        // image made from it is a copy.
317        let ctx = unsafe { CGBitmapContextCreate(px.as_mut_ptr().cast(), w, h, 8, w * 4, Some(&space), info) }.ok_or("no bitmap context")?;
318        CGBitmapContextCreateImage(Some(&ctx)).ok_or_else(|| "no image".to_string())
319    }
320
321    pub fn write(path: &Path, uti: &str, frames: &[super::WriteFrame]) -> Result<(), String> {
322        let url = CFURL::from_file_path(path).ok_or("not a file path")?;
323        // SAFETY: owned arguments; no options.
324        let dest = unsafe { CGImageDestination::with_url(&url, &CFString::from_str(uti), frames.len(), None) }
325            .ok_or_else(|| format!("this system can't write {uti}"))?;
326        for f in frames {
327            let mut props = vec![("Orientation", obj(NSNumber::new_u8(f.orientation)))];
328            if let Some(d) = f.delay {
329                props.push(("{HEICS}", obj(dict(vec![("DelayTime", obj(NSNumber::new_f64(d)))]))));
330            }
331            let props = dict(props);
332            let image = cg_image(f.image)?;
333            // SAFETY: NSDictionary is toll-free bridged to CFDictionary;
334            // owned arguments.
335            unsafe { dest.add_image(&image, Some(&*Retained::as_ptr(&props).cast::<CFDictionary>())) };
336        }
337        // SAFETY: owned.
338        if unsafe { dest.finalize() } { Ok(()) } else { Err(format!("writing {uti} failed")) }
339    }
340
341    pub struct Source {
342        src: CFRetained<CGImageSource>,
343    }
344
345    impl Source {
346        pub fn open(path: &Path) -> Result<Option<(Source, Vec<u32>)>, String> {
347            let src = open_source(path)?;
348            // SAFETY: owned.
349            let count = unsafe { src.count() };
350            let first = properties(&src, 0).and_then(|p| frame_delay(as_ns(&p)));
351            if !is_animated(count, first.is_some()) {
352                return Ok(None);
353            }
354            let delays = (0..count).map(|i| properties(&src, i).and_then(|p| frame_delay(as_ns(&p))).unwrap_or(0)).collect();
355            Ok(Some((Source { src }, delays)))
356        }
357
358        pub fn size(&self, i: usize) -> (u32, u32) {
359            let Some(p) = properties(&self.src, i) else { return (0, 0) };
360            let p = as_ns(&p);
361            let (w, h) = (number(p, "PixelWidth").unwrap_or(0.0) as u32, number(p, "PixelHeight").unwrap_or(0.0) as u32);
362            super::oriented_size(w, h, orientation_at(&self.src, i))
363        }
364
365        pub fn frame(&mut self, i: usize) -> Result<RgbaImage, String> {
366            decode_at(&self.src, i)
367        }
368    }
369}
370
371#[cfg(not(target_os = "macos"))]
372mod imp {
373    use super::{DynamicImage, Path, RgbaImage, ThumbError};
374
375    pub fn decode(_: &Path) -> Result<DynamicImage, ThumbError> {
376        Err(ThumbError("ImageIO is only available on macOS".into()))
377    }
378
379    pub fn readable_types() -> Vec<String> {
380        Vec::new()
381    }
382
383    pub fn decode_bytes_within(_: &[u8], _: u32) -> Result<RgbaImage, String> {
384        Err("ImageIO is only available on macOS".into())
385    }
386
387    pub fn write(_: &Path, _: &str, _: &[super::WriteFrame]) -> Result<(), String> {
388        Err("ImageIO is only available on macOS".into())
389    }
390
391    pub enum Source {}
392
393    impl Source {
394        pub fn open(_: &Path) -> Result<Option<(Source, Vec<u32>)>, String> {
395            Ok(None)
396        }
397
398        pub fn size(&self, _: usize) -> (u32, u32) {
399            match *self {}
400        }
401
402        pub fn frame(&mut self, _: usize) -> Result<RgbaImage, String> {
403            match *self {}
404        }
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn extensions_map_to_imageio_types() {
414        assert_eq!(type_of("avif"), Some("public.avif"));
415        assert_eq!(type_of("hif"), Some("public.heic"));
416        assert_eq!(type_of("jxl"), Some("public.jpeg-xl"));
417        assert!(handles("HEIC") && handles("heif") && handles("avifs") && handles("heics"));
418        assert!(!handles("jpg") && !handles("png") && !handles(""));
419    }
420
421    #[test]
422    fn exif_orientations_map_and_turn_sizes() {
423        assert_eq!(orientation(1), Orientation::NoTransforms);
424        assert_eq!(orientation(3), Orientation::Rotate180);
425        assert_eq!(orientation(6), Orientation::Rotate90);
426        assert_eq!(orientation(8), Orientation::Rotate270);
427        assert_eq!(orientation(2), Orientation::FlipHorizontal);
428        // Out of range values leave the picture alone.
429        assert_eq!(orientation(0), Orientation::NoTransforms);
430        assert_eq!(orientation(9), Orientation::NoTransforms);
431        assert_eq!(orientation(-1), Orientation::NoTransforms);
432        assert_eq!(orientation(300), Orientation::NoTransforms);
433        assert_eq!(oriented_size(40, 20, orientation(6)), (20, 40));
434        assert_eq!(oriented_size(40, 20, orientation(5)), (20, 40));
435        assert_eq!(oriented_size(40, 20, orientation(3)), (40, 20));
436    }
437
438    #[test]
439    fn delays_prefer_the_unclamped_value() {
440        assert_eq!(delay_ms(Some(0.02), Some(0.1)), 20);
441        assert_eq!(delay_ms(None, Some(0.25)), 250);
442        assert_eq!(delay_ms(Some(0.0), Some(0.1)), 100);
443        assert_eq!(delay_ms(Some(f64::NAN), None), 0);
444        assert_eq!(delay_ms(None, None), 0);
445        assert_eq!(delay_ms(None, Some(-1.0)), 0);
446        assert_eq!(delay_ms(Some(1.0 / 30.0), None), 33);
447    }
448
449    #[test]
450    fn only_timed_sequences_animate() {
451        assert!(is_animated(3, true));
452        assert!(!is_animated(3, false), "an image collection is a still");
453        assert!(!is_animated(1, true));
454    }
455
456    #[test]
457    fn unpremultiplies_partial_alpha() {
458        let mut px = [64, 32, 0, 128, 10, 20, 30, 255, 0, 0, 0, 0];
459        unpremultiply(&mut px);
460        assert_eq!(px, [128, 64, 0, 128, 10, 20, 30, 255, 0, 0, 0, 0]);
461    }
462}