Skip to main content

rawshift_image/codecs/
heic.rs

1//! Safe wrapper around libheif (via the `libheif-rs` crate) for HEIC/HEIF decoding.
2//!
3//! All `libheif-rs`/`libheif` interaction is confined to this module. Callers
4//! receive plain-data structs and `Result<_, String>`; `src/formats` maps the
5//! `String` into a [`FormatError::ImageDecode`](crate::error::FormatError).
6//!
7//! libheif handles ISOBMFF container parsing, HEVC decoding, grid (tiled) image
8//! stitching, and the `irot`/`imir` geometric transforms — see [`decode_primary`].
9
10use libheif_rs::{
11    AuxiliaryImagesFilter, ColorProfile, ColorSpace, HeifContext, ImageHandle, LibHeif, RgbChroma,
12};
13
14/// A decoded HEIC image as interleaved 16-bit RGB (alpha dropped).
15///
16/// libheif applies the container's `irot`/`imir` geometric transforms during
17/// decode, so `rgb` is already correctly oriented. 8-bit sources are scaled to
18/// 16-bit by `*257`; HDR (10/12-bit) sources are bit-replicated to full range.
19pub struct DecodedHeic {
20    /// Image width in pixels.
21    pub width: u32,
22    /// Image height in pixels.
23    pub height: u32,
24    /// Interleaved RGB samples, row-major, length `width * height * 3`.
25    pub rgb: Vec<u16>,
26}
27
28/// Classification of an auxiliary/derived image inside a HEIC file.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub enum HeicAuxKind {
32    /// A scaled-down preview thumbnail.
33    Thumbnail,
34    /// A depth or disparity map.
35    DepthMap,
36    /// An HDR gain map (e.g. Apple `urn:com:apple:photo:2020:aux:hdrgainmap`).
37    GainMap,
38    /// Any other auxiliary image (alpha mask, unrecognised URN, …).
39    Auxiliary,
40}
41
42/// Lightweight descriptor of one auxiliary image referenced by the primary image.
43pub struct HeicAuxInfo {
44    /// What kind of auxiliary image this is.
45    pub kind: HeicAuxKind,
46    /// Auxiliary image width in pixels.
47    pub width: u32,
48    /// Auxiliary image height in pixels.
49    pub height: u32,
50    /// libheif item id — pass to [`decode_aux`] to decode this image.
51    pub item_id: u32,
52    /// Auxiliary type URN string, when the container provides one.
53    pub aux_type: Option<String>,
54}
55
56/// Raw metadata blocks and container facts pulled from a HEIC file.
57#[derive(Default)]
58pub struct HeicMetaBlobs {
59    /// Clean TIFF-structured EXIF byte stream (libheif's 4-byte offset prefix
60    /// stripped), if present.
61    pub exif: Option<Vec<u8>>,
62    /// Raw XMP (RDF/XML) packet bytes, if present.
63    pub xmp: Option<Vec<u8>>,
64    /// Raw embedded ICC color profile bytes (`rICC`/`prof` boxes), if present.
65    pub icc: Option<Vec<u8>>,
66    /// Luma bits-per-channel of the primary image (8, 10, 12, …).
67    pub bit_depth: u8,
68    /// Whether the primary image carries an alpha channel.
69    pub has_alpha: bool,
70    /// Primary image width in pixels.
71    pub width: u32,
72    /// Primary image height in pixels.
73    pub height: u32,
74}
75
76/// Open a HEIF context over `data`.
77///
78/// `libheif` borrows the buffer without copying, so the returned context
79/// borrows `data` for its lifetime.
80fn open_ctx(data: &[u8]) -> Result<HeifContext<'_>, String> {
81    HeifContext::read_from_bytes(data).map_err(|e| format!("failed to parse HEIC container: {e}"))
82}
83
84/// Bit-replicate a `depth`-bit sample up to the full 16-bit range.
85///
86/// This matches the `*257` convention used for 8-bit sources elsewhere in the
87/// crate: it is exact at both `0` and the maximum value (for `depth` in `8..=15`).
88#[inline]
89fn upscale_to_u16(v: u16, depth: u8) -> u16 {
90    if depth >= 16 || depth == 0 {
91        return v;
92    }
93    let d = depth as u32;
94    let v = v as u32;
95    let scaled = if 2 * d >= 16 {
96        (v << (16 - d)) | (v >> (2 * d - 16))
97    } else {
98        v << (16 - d)
99    };
100    scaled as u16
101}
102
103/// Decode an [`ImageHandle`] to interleaved 16-bit RGB.
104fn handle_to_decoded(lib: &LibHeif, handle: &ImageHandle) -> Result<DecodedHeic, String> {
105    let bit_depth = handle.luma_bits_per_pixel().max(8);
106    let hdr = bit_depth > 8;
107    let chroma = if hdr {
108        RgbChroma::HdrRgbBe
109    } else {
110        RgbChroma::Rgb
111    };
112
113    // libheif's high-level decode also carries out all geometric transformations
114    // specified in the container (rotation, mirroring, cropping) and stitches
115    // `grid` (tiled) images, so the output needs no further fix-up.
116    let image = lib
117        .decode(handle, ColorSpace::Rgb(chroma), None)
118        .map_err(|e| format!("HEVC decode failed: {e}"))?;
119
120    let planes = image.planes();
121    let plane = planes
122        .interleaved
123        .ok_or_else(|| "decoded HEIC image has no interleaved RGB plane".to_string())?;
124
125    let w = plane.width as usize;
126    let h = plane.height as usize;
127    let stride = plane.stride;
128    let data = plane.data;
129    let mut rgb = vec![0u16; w * h * 3];
130
131    if hdr {
132        // 6 bytes per pixel: three big-endian 16-bit samples. The real
133        // per-channel depth is `plane.bits_per_pixel`; the value occupies the
134        // low bits of each 16-bit word.
135        let depth = if plane.bits_per_pixel == 0 {
136            bit_depth
137        } else {
138            plane.bits_per_pixel
139        };
140        let mask: u16 = if depth >= 16 {
141            u16::MAX
142        } else {
143            (1u16 << depth) - 1
144        };
145        for y in 0..h {
146            let row_start = y * stride;
147            let row = data
148                .get(row_start..row_start + w * 6)
149                .ok_or_else(|| "HEIC HDR plane shorter than expected".to_string())?;
150            for x in 0..w {
151                let dst = (y * w + x) * 3;
152                for c in 0..3 {
153                    let hi = row[x * 6 + c * 2] as u16;
154                    let lo = row[x * 6 + c * 2 + 1] as u16;
155                    let v = (((hi << 8) | lo) & mask).min(mask);
156                    rgb[dst + c] = upscale_to_u16(v, depth);
157                }
158            }
159        }
160    } else {
161        // 3 bytes per pixel, 8-bit samples scaled to 16-bit by `*257`.
162        for y in 0..h {
163            let row_start = y * stride;
164            let row = data
165                .get(row_start..row_start + w * 3)
166                .ok_or_else(|| "HEIC RGB plane shorter than expected".to_string())?;
167            for x in 0..w {
168                let dst = (y * w + x) * 3;
169                rgb[dst] = (row[x * 3] as u16) * 257;
170                rgb[dst + 1] = (row[x * 3 + 1] as u16) * 257;
171                rgb[dst + 2] = (row[x * 3 + 2] as u16) * 257;
172            }
173        }
174    }
175
176    Ok(DecodedHeic {
177        width: plane.width,
178        height: plane.height,
179        rgb,
180    })
181}
182
183/// Decode the primary image of a HEIC file to interleaved 16-bit RGB.
184pub fn decode_primary(data: &[u8]) -> Result<DecodedHeic, String> {
185    // TODO(platform): on Apple targets a hardware-accelerated path via ImageIO
186    //   (CGImageSource) and on Android via the NDK ImageDecoder could be
187    //   selected here. A single cross-platform libheif backend is used for now.
188    let ctx = open_ctx(data)?;
189    let lib = LibHeif::new();
190    let handle = ctx
191        .primary_image_handle()
192        .map_err(|e| format!("no primary image in HEIC file: {e}"))?;
193    handle_to_decoded(&lib, &handle)
194}
195
196/// Classify an auxiliary image from its type URN.
197fn classify_aux(aux_type: Option<&str>) -> HeicAuxKind {
198    match aux_type {
199        Some(t) => {
200            let l = t.to_ascii_lowercase();
201            if l.contains("gainmap") || l.contains("hdrgain") {
202                HeicAuxKind::GainMap
203            } else if l.contains("depth") || l.contains("disparity") {
204                HeicAuxKind::DepthMap
205            } else {
206                HeicAuxKind::Auxiliary
207            }
208        }
209        None => HeicAuxKind::Auxiliary,
210    }
211}
212
213/// Enumerate the thumbnails, depth maps, gain maps, and auxiliary images
214/// referenced by the primary image.
215pub fn list_aux_images(data: &[u8]) -> Result<Vec<HeicAuxInfo>, String> {
216    // TODO(platform): platform-native containers expose the same items; the
217    //   libheif enumeration below is cross-platform.
218    let ctx = open_ctx(data)?;
219    let handle = ctx
220        .primary_image_handle()
221        .map_err(|e| format!("no primary image in HEIC file: {e}"))?;
222    let mut out = Vec::new();
223
224    // Thumbnails.
225    let n_thumbs = handle.number_of_thumbnails();
226    if n_thumbs > 0 {
227        let mut ids = vec![0u32; n_thumbs];
228        let got = handle.thumbnail_ids(&mut ids);
229        ids.truncate(got);
230        for id in ids {
231            if let Ok(th) = handle.thumbnail(id) {
232                out.push(HeicAuxInfo {
233                    kind: HeicAuxKind::Thumbnail,
234                    width: th.width(),
235                    height: th.height(),
236                    item_id: id,
237                    aux_type: None,
238                });
239            }
240        }
241    }
242
243    // Depth images.
244    let n_depth = handle.number_of_depth_images().max(0) as usize;
245    if n_depth > 0 {
246        let mut ids = vec![0u32; n_depth];
247        let got = handle.depth_image_ids(&mut ids);
248        ids.truncate(got);
249        for id in ids {
250            if let Ok(dh) = handle.depth_image_handle(id) {
251                out.push(HeicAuxInfo {
252                    kind: HeicAuxKind::DepthMap,
253                    width: dh.width(),
254                    height: dh.height(),
255                    item_id: id,
256                    aux_type: None,
257                });
258            }
259        }
260    }
261
262    // Auxiliary images (alpha, gain maps, …). Depth is enumerated above, so it
263    // is omitted here to avoid listing the same item twice.
264    for aux in handle.auxiliary_images(AuxiliaryImagesFilter::new().omit_depth()) {
265        let aux_type = aux.auxiliary_type().ok().filter(|s| !s.is_empty());
266        out.push(HeicAuxInfo {
267            kind: classify_aux(aux_type.as_deref()),
268            width: aux.width(),
269            height: aux.height(),
270            item_id: aux.item_id(),
271            aux_type,
272        });
273    }
274
275    Ok(out)
276}
277
278/// Decode a single auxiliary image by its libheif item id.
279pub fn decode_aux(data: &[u8], item_id: u32) -> Result<DecodedHeic, String> {
280    let ctx = open_ctx(data)?;
281    let lib = LibHeif::new();
282    let handle = ctx
283        .image_handle(item_id)
284        .map_err(|e| format!("HEIC auxiliary image {item_id} not found: {e}"))?;
285    handle_to_decoded(&lib, &handle)
286}
287
288/// libheif stores the EXIF item with a leading 4-byte big-endian offset that
289/// points at the start of the TIFF header. Strip it so the result is a clean
290/// TIFF byte stream.
291fn strip_exif_prefix(raw: Vec<u8>) -> Option<Vec<u8>> {
292    if raw.len() < 4 {
293        return None;
294    }
295    let offset = u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]) as usize;
296    let start = 4usize.checked_add(offset)?;
297    if start >= raw.len() {
298        return None;
299    }
300    Some(raw[start..].to_vec())
301}
302
303/// Pull EXIF / XMP / ICC metadata blocks and basic container facts from a HEIC file.
304pub fn extract_metadata_blobs(data: &[u8]) -> Result<HeicMetaBlobs, String> {
305    let ctx = open_ctx(data)?;
306    let handle = ctx
307        .primary_image_handle()
308        .map_err(|e| format!("no primary image in HEIC file: {e}"))?;
309
310    let mut blobs = HeicMetaBlobs {
311        bit_depth: handle.luma_bits_per_pixel().max(8),
312        has_alpha: handle.has_alpha_channel(),
313        width: handle.width(),
314        height: handle.height(),
315        ..Default::default()
316    };
317
318    for md in handle.all_metadata() {
319        if &md.item_type.0 == b"Exif" {
320            blobs.exif = strip_exif_prefix(md.raw_data);
321        } else if md.content_type == "application/rdf+xml" {
322            blobs.xmp = Some(md.raw_data);
323        }
324    }
325
326    if let Some(profile) = handle.color_profile_raw() {
327        // Keep only genuine ICC profiles (`rICC`/`prof`); skip `nclx`.
328        let typ = profile.profile_type().0;
329        if &typ == b"rICC" || &typ == b"prof" {
330            blobs.icc = Some(profile.data);
331        }
332    }
333
334    Ok(blobs)
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn upscale_endpoints_are_exact() {
343        // 8-bit: 0 -> 0, 255 -> 65535 (matches the `*257` convention).
344        assert_eq!(upscale_to_u16(0, 8), 0);
345        assert_eq!(upscale_to_u16(255, 8), 65535);
346        // 10-bit: 0 -> 0, 1023 -> 65535.
347        assert_eq!(upscale_to_u16(0, 10), 0);
348        assert_eq!(upscale_to_u16(1023, 10), 65535);
349        // 12-bit: 4095 -> 65535.
350        assert_eq!(upscale_to_u16(4095, 12), 65535);
351        // 16-bit: identity.
352        assert_eq!(upscale_to_u16(12345, 16), 12345);
353    }
354
355    #[test]
356    fn strip_exif_prefix_zero_offset() {
357        // 4-byte offset of 0 -> TIFF stream starts immediately after the prefix.
358        let raw = vec![0, 0, 0, 0, b'I', b'I', 0x2A, 0x00];
359        assert_eq!(strip_exif_prefix(raw), Some(vec![b'I', b'I', 0x2A, 0x00]));
360    }
361
362    #[test]
363    fn strip_exif_prefix_nonzero_offset() {
364        // Offset of 2 -> skip 4-byte prefix + 2 pad bytes.
365        let raw = vec![0, 0, 0, 2, 0xAA, 0xBB, b'M', b'M'];
366        assert_eq!(strip_exif_prefix(raw), Some(vec![b'M', b'M']));
367    }
368
369    #[test]
370    fn strip_exif_prefix_rejects_short_or_oob() {
371        assert_eq!(strip_exif_prefix(vec![0, 0]), None);
372        assert_eq!(strip_exif_prefix(vec![0, 0, 0, 99, 1, 2]), None);
373    }
374
375    #[test]
376    fn classify_aux_recognises_urns() {
377        assert_eq!(
378            classify_aux(Some("urn:com:apple:photo:2020:aux:hdrgainmap")),
379            HeicAuxKind::GainMap
380        );
381        assert_eq!(
382            classify_aux(Some("urn:mpeg:hevc:2015:auxid:2:depth")),
383            HeicAuxKind::DepthMap
384        );
385        assert_eq!(
386            classify_aux(Some("urn:mpeg:hevc:2015:auxid:1")),
387            HeicAuxKind::Auxiliary
388        );
389        assert_eq!(classify_aux(None), HeicAuxKind::Auxiliary);
390    }
391
392    #[test]
393    fn decode_primary_rejects_junk() {
394        let junk = vec![0u8; 64];
395        assert!(decode_primary(&junk).is_err());
396    }
397
398    #[test]
399    fn extract_metadata_blobs_rejects_junk() {
400        let junk = vec![0u8; 64];
401        assert!(extract_metadata_blobs(&junk).is_err());
402    }
403
404    /// Decode libheif's bundled sample file when it is available on this
405    /// machine (Homebrew install). Skips gracefully otherwise.
406    #[test]
407    fn decode_primary_homebrew_sample() {
408        let candidates = [
409            "/opt/homebrew/share/libheif/example.heic",
410            "/usr/local/share/libheif/example.heic",
411        ];
412        let Some(path) = candidates.iter().find(|p| std::path::Path::new(p).exists()) else {
413            eprintln!("skipping: no libheif sample HEIC found");
414            return;
415        };
416        let data = std::fs::read(path).expect("read sample heic");
417        let decoded = decode_primary(&data).expect("decode sample heic");
418        assert!(decoded.width > 0 && decoded.height > 0);
419        assert_eq!(
420            decoded.rgb.len(),
421            decoded.width as usize * decoded.height as usize * 3
422        );
423    }
424}