Skip to main content

ultrahdr_rs/
decode.rs

1//! Ultra HDR decoder.
2
3#[cfg(feature = "_test-helpers")]
4use ultrahdr_core::gainmap::apply::{HdrOutputFormat, apply_gainmap};
5use ultrahdr_core::metadata::{mpf::find_jpeg_boundaries, xmp::parse_xmp};
6#[cfg(feature = "_test-helpers")]
7use ultrahdr_core::{ColorGamut, ColorTransfer, PixelFormat, Unstoppable};
8use ultrahdr_core::{Error, GainMap, GainMapMetadata, RawImage, Result};
9
10use crate::container::{self, AppSegment};
11
12/// Ultra HDR decoder.
13///
14/// Decodes Ultra HDR JPEGs, extracting the SDR base image, gain map,
15/// and metadata. Can reconstruct HDR content at various display
16/// brightness levels.
17///
18/// The decoder borrows the input data to avoid an unconditional copy.
19pub struct Decoder<'a> {
20    data: &'a [u8],
21    metadata: Option<GainMapMetadata>,
22    primary_jpeg: Option<(usize, usize)>,
23    gainmap_jpeg: Option<(usize, usize)>,
24    is_ultrahdr: bool,
25}
26
27impl<'a> Decoder<'a> {
28    /// Create a new decoder from JPEG data.
29    ///
30    /// The decoder borrows the data — no copy is made.
31    pub fn new(data: &'a [u8]) -> Result<Self> {
32        let mut decoder = Self {
33            data,
34            metadata: None,
35            primary_jpeg: None,
36            gainmap_jpeg: None,
37            is_ultrahdr: false,
38        };
39
40        decoder.parse()?;
41        Ok(decoder)
42    }
43
44    /// Check if this is a valid Ultra HDR image.
45    pub fn is_ultrahdr(&self) -> bool {
46        self.is_ultrahdr
47    }
48
49    /// Get the gain map metadata.
50    pub fn metadata(&self) -> Option<&GainMapMetadata> {
51        self.metadata.as_ref()
52    }
53
54    /// Get the raw primary (SDR base) JPEG data.
55    ///
56    /// Use this to decode the base image with your own JPEG codec.
57    pub fn primary_jpeg(&self) -> Option<&[u8]> {
58        self.primary_jpeg
59            .and_then(|(start, end)| self.data.get(start..end))
60    }
61
62    /// Get the raw gain map JPEG data.
63    ///
64    /// Use this to decode the gain map with your own JPEG codec.
65    pub fn gainmap_jpeg(&self) -> Option<&[u8]> {
66        self.gainmap_jpeg
67            .and_then(|(start, end)| self.data.get(start..end))
68    }
69
70    /// Decode the SDR base image.
71    ///
72    /// Note: This method requires a JPEG codec and is only available in tests.
73    /// For production use, access the raw JPEG bytes via [`Decoder::primary_jpeg`] and
74    /// decode with your own codec.
75    #[cfg(feature = "_test-helpers")]
76    pub fn decode_sdr(&self) -> Result<RawImage> {
77        let primary_data = self
78            .primary_jpeg()
79            .ok_or_else(|| Error::DecodeError("No primary image found".into()))?;
80        decode_jpeg_to_rgb(primary_data)
81    }
82
83    /// Decode the SDR base image.
84    ///
85    /// This method is not available in the library. Access the raw JPEG bytes
86    /// via [`Decoder::primary_jpeg`] and decode with your own codec.
87    #[cfg(not(feature = "_test-helpers"))]
88    pub fn decode_sdr(&self) -> Result<RawImage> {
89        Err(Error::DecodeError(
90            "decode_sdr() requires a JPEG codec. Use primary_jpeg() to get raw bytes \
91             and decode with your own codec"
92                .into(),
93        ))
94    }
95
96    /// Decode the gain map.
97    ///
98    /// Note: This method requires a JPEG codec and is only available in tests.
99    /// For production use, access the raw JPEG bytes via [`Decoder::gainmap_jpeg`] and
100    /// decode with your own codec.
101    #[cfg(feature = "_test-helpers")]
102    pub fn decode_gainmap(&self) -> Result<GainMap> {
103        let gainmap_data = self
104            .gainmap_jpeg()
105            .ok_or_else(|| Error::DecodeError("No gain map found".into()))?;
106        let decoded = decode_jpeg_to_grayscale(gainmap_data)?;
107
108        Ok(GainMap {
109            width: decoded.width,
110            height: decoded.height,
111            channels: 1,
112            data: decoded.data,
113        })
114    }
115
116    /// Decode the gain map.
117    ///
118    /// This method is not available in the library. Access the raw JPEG bytes
119    /// via [`Decoder::gainmap_jpeg`] and decode with your own codec.
120    #[cfg(not(feature = "_test-helpers"))]
121    pub fn decode_gainmap(&self) -> Result<GainMap> {
122        Err(Error::DecodeError(
123            "decode_gainmap() requires a JPEG codec. Use gainmap_jpeg() to get raw bytes \
124             and decode with your own codec"
125                .into(),
126        ))
127    }
128
129    /// Decode to HDR at the specified display boost level.
130    ///
131    /// `display_boost` is the ratio of display peak brightness to SDR white.
132    /// For example:
133    /// - 1.0 = SDR display (no HDR enhancement)
134    /// - 4.0 = Display capable of 4x SDR brightness
135    /// - ~49.0 = Full HDR10 (10000 nits / 203 SDR nits)
136    ///
137    /// Note: This method requires a JPEG codec and is only available in tests.
138    /// For production use, decode the JPEGs yourself using [`Decoder::primary_jpeg`] and
139    /// [`Decoder::gainmap_jpeg`], then call [`ultrahdr_core::gainmap::apply::apply_gainmap`].
140    #[cfg(feature = "_test-helpers")]
141    pub fn decode_hdr(&self, display_boost: f32) -> Result<RawImage> {
142        self.decode_hdr_with_format(display_boost, HdrOutputFormat::LinearFloat)
143    }
144
145    /// Decode to HDR with a specific output format.
146    ///
147    /// Note: This method requires a JPEG codec and is only available in tests.
148    #[cfg(feature = "_test-helpers")]
149    pub fn decode_hdr_with_format(
150        &self,
151        display_boost: f32,
152        format: HdrOutputFormat,
153    ) -> Result<RawImage> {
154        if !self.is_ultrahdr {
155            return Err(Error::DecodeError("Not an Ultra HDR image".into()));
156        }
157
158        if !display_boost.is_finite() || display_boost < 1.0 {
159            return Err(Error::DecodeError(format!(
160                "display_boost must be >= 1.0, got {}",
161                display_boost
162            )));
163        }
164
165        let metadata = self
166            .metadata
167            .as_ref()
168            .ok_or_else(|| Error::DecodeError("No gain map metadata".into()))?;
169
170        let sdr = self.decode_sdr()?;
171        let gainmap = self.decode_gainmap()?;
172
173        apply_gainmap(&sdr, &gainmap, metadata, display_boost, format, Unstoppable)
174    }
175
176    /// Parse the Ultra HDR structure.
177    ///
178    /// Uses `container::scan_segments` for efficient marker-to-marker scanning
179    /// instead of byte-by-byte search.
180    fn parse(&mut self) -> Result<()> {
181        // Check for valid JPEG
182        if self.data.len() < 4 || self.data[0] != 0xFF || self.data[1] != 0xD8 {
183            return Err(Error::DecodeError("Not a valid JPEG".into()));
184        }
185
186        // Scan APP segments efficiently (walks marker-to-marker, not byte-by-byte)
187        let segments = container::scan_segments(self.data);
188
189        // Find XMP metadata with hdrgm namespace in primary
190        if let Some(xmp_str) = find_xmp_in_segments(&segments)
191            && (xmp_str.contains("hdrgm:") || xmp_str.contains("http://ns.adobe.com/hdr-gain-map/"))
192        {
193            self.is_ultrahdr = true;
194            // Try parsing numeric metadata from primary XMP (legacy format)
195            if let Ok((metadata, _gainmap_len)) = parse_xmp(&xmp_str)
196                && (metadata.alternate_hdr_headroom != 0.0 || metadata.gain_map_max != [0.0; 3])
197            {
198                self.metadata = Some(metadata);
199            }
200        }
201
202        // Try to parse MPF to find gain map (reuses container module's parser)
203        if let Some(mpf_seg) = segments.iter().find(|s| s.is_mpf())
204            && let Ok(mpf_dir) = container::parse_mpf_segment(&mpf_seg.data, mpf_seg.offset)
205            && mpf_dir.entries.len() >= 2
206        {
207            // Primary image
208            let primary_size = mpf_dir.entries[0].size as usize;
209            self.primary_jpeg = Some((0, primary_size));
210
211            // Secondary images (gain map)
212            let secondaries = container::extract_secondary_images(self.data, &mpf_dir);
213            if let Some(gm) = secondaries.first() {
214                let gm_start = gm.as_ptr() as usize - self.data.as_ptr() as usize;
215                self.gainmap_jpeg = Some((gm_start, gm_start + gm.len()));
216                self.is_ultrahdr = true;
217
218                // Check gain map JPEG for metadata XMP (modern format:
219                // libultrahdr puts metadata in the secondary JPEG's XMP)
220                if self.metadata.is_none() {
221                    let gm_segments = container::scan_segments(gm);
222                    if let Some(gm_xmp) = find_xmp_in_segments(&gm_segments)
223                        && gm_xmp.contains("hdrgm:")
224                        && let Ok((gm_metadata, _)) = parse_xmp(&gm_xmp)
225                    {
226                        self.metadata = Some(gm_metadata);
227                    }
228                }
229            }
230        }
231
232        // Fallback: look for multiple JPEGs in the file
233        if self.gainmap_jpeg.is_none() {
234            let boundaries = find_jpeg_boundaries(self.data);
235            if boundaries.len() >= 2 {
236                self.primary_jpeg = Some(boundaries[0]);
237                self.gainmap_jpeg = Some(boundaries[1]);
238
239                // Also try to find metadata in the gain map JPEG
240                if self.metadata.is_none()
241                    && let (gm_start, gm_end) = boundaries[1]
242                    && let Some(gm_data) = self.data.get(gm_start..gm_end)
243                {
244                    let gm_segments = container::scan_segments(gm_data);
245                    if let Some(gm_xmp) = find_xmp_in_segments(&gm_segments)
246                        && gm_xmp.contains("hdrgm:")
247                        && let Ok((gm_metadata, _)) = parse_xmp(&gm_xmp)
248                    {
249                        self.metadata = Some(gm_metadata);
250                    }
251                }
252            }
253        }
254
255        // Set primary to full data if not found via MPF
256        if self.primary_jpeg.is_none() {
257            self.primary_jpeg = Some((0, self.data.len()));
258        }
259
260        Ok(())
261    }
262
263    /// Get the ICC profile from the primary image if present.
264    pub fn icc_profile(&self) -> Option<Vec<u8>> {
265        crate::jpeg::extract_icc_profile(self.data)
266    }
267
268    /// Get information about the decoded image dimensions.
269    ///
270    /// Note: This method requires a JPEG codec and is only available in tests.
271    #[cfg(feature = "_test-helpers")]
272    pub fn dimensions(&self) -> Result<(u32, u32)> {
273        let sdr = self.decode_sdr()?;
274        Ok((sdr.width, sdr.height))
275    }
276}
277
278/// Find XMP data in pre-scanned APP segments.
279///
280/// This is O(segments) instead of O(bytes), since we use the already-scanned
281/// segment list from `container::scan_segments`.
282fn find_xmp_in_segments(segments: &[AppSegment]) -> Option<String> {
283    let xmp_ns = b"http://ns.adobe.com/xap/1.0/\0";
284
285    for seg in segments {
286        if seg.is_xmp() && seg.data.len() > xmp_ns.len() {
287            let xmp_bytes = &seg.data[xmp_ns.len()..];
288            if let Ok(xmp) = std::str::from_utf8(xmp_bytes) {
289                return Some(xmp.to_string());
290            }
291        }
292    }
293
294    None
295}
296
297/// Decode JPEG to RGB.
298#[cfg(feature = "_test-helpers")]
299fn decode_jpeg_to_rgb(jpeg_data: &[u8]) -> Result<RawImage> {
300    use zenjpeg::decoder::{Decoder as JpegDecoder, PixelFormat as JpegPixelFormat};
301    let decoded = JpegDecoder::new()
302        .output_format(JpegPixelFormat::Rgb)
303        .decode(jpeg_data, Unstoppable)
304        .map_err(|e| Error::DecodeError(format!("JPEG decode failed: {}", e)))?;
305
306    let width = decoded.width();
307    let height = decoded.height();
308    let pixels = decoded
309        .pixels_u8()
310        .ok_or_else(|| Error::DecodeError("No pixel data in decoded JPEG".into()))?;
311    let bpp = decoded.bytes_per_pixel();
312
313    // Convert to RGBA if needed
314    let data = if bpp == 3 {
315        // RGB -> RGBA
316        let mut rgba = Vec::with_capacity((width * height * 4) as usize);
317        for chunk in pixels.chunks(3) {
318            rgba.push(chunk[0]);
319            rgba.push(chunk[1]);
320            rgba.push(chunk[2]);
321            rgba.push(255);
322        }
323        rgba
324    } else if bpp == 4 {
325        pixels.to_vec()
326    } else if bpp == 1 {
327        // Grayscale -> RGBA
328        let mut rgba = Vec::with_capacity((width * height * 4) as usize);
329        for &g in pixels {
330            rgba.push(g);
331            rgba.push(g);
332            rgba.push(g);
333            rgba.push(255);
334        }
335        rgba
336    } else {
337        return Err(Error::DecodeError(format!(
338            "Unsupported bytes per pixel: {}",
339            bpp
340        )));
341    };
342
343    Ok(RawImage {
344        width,
345        height,
346        stride: width * 4,
347        data,
348        format: PixelFormat::Rgba8,
349        gamut: ColorGamut::Bt709, // Assume sRGB for SDR
350        transfer: ColorTransfer::Srgb,
351    })
352}
353
354/// Decode JPEG to grayscale.
355#[cfg(feature = "_test-helpers")]
356fn decode_jpeg_to_grayscale(jpeg_data: &[u8]) -> Result<RawImage> {
357    use zenjpeg::decoder::{Decoder as JpegDecoder, PixelFormat as JpegPixelFormat};
358    let decoded = JpegDecoder::new()
359        .output_format(JpegPixelFormat::Gray)
360        .decode(jpeg_data, Unstoppable)
361        .map_err(|e| Error::DecodeError(format!("JPEG decode failed: {}", e)))?;
362
363    let width = decoded.width();
364    let height = decoded.height();
365    let pixels = decoded
366        .pixels_u8()
367        .ok_or_else(|| Error::DecodeError("No pixel data in decoded JPEG".into()))?;
368    let bpp = decoded.bytes_per_pixel();
369
370    // Convert to grayscale if needed
371    let data = if bpp == 1 {
372        pixels.to_vec()
373    } else if bpp == 3 {
374        // RGB -> Grayscale (using luminance)
375        pixels
376            .chunks(3)
377            .map(|rgb| {
378                let r = rgb[0] as f32;
379                let g = rgb[1] as f32;
380                let b = rgb[2] as f32;
381                // BT.709 luminance
382                (0.2126_f32 * r + 0.7152 * g + 0.0722 * b).clamp(0.0, 255.0) as u8
383            })
384            .collect()
385    } else {
386        return Err(Error::DecodeError(format!(
387            "Unsupported bytes per pixel for grayscale: {}",
388            bpp
389        )));
390    };
391
392    Ok(RawImage {
393        width,
394        height,
395        stride: width,
396        data,
397        format: PixelFormat::Gray8,
398        gamut: ColorGamut::Bt709,
399        transfer: ColorTransfer::Srgb,
400    })
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn test_decoder_invalid_data() {
409        let result = Decoder::new(&[0, 1, 2, 3]);
410        assert!(result.is_err());
411    }
412
413    #[test]
414    fn test_decoder_minimal_jpeg() {
415        // Minimal JPEG (just SOI + EOI)
416        let data = vec![0xFF, 0xD8, 0xFF, 0xD9];
417        let decoder = Decoder::new(&data);
418        assert!(decoder.is_ok());
419        assert!(!decoder.unwrap().is_ultrahdr());
420    }
421
422    #[test]
423    fn test_decoder_not_ultrahdr() {
424        // JPEG with APP0 but no UltraHDR content
425        let data = vec![
426            0xFF, 0xD8, // SOI
427            0xFF, 0xE0, 0x00, 0x07, // APP0 length 7
428            b'J', b'F', b'I', b'F', 0x00, // JFIF
429            0xFF, 0xD9, // EOI
430        ];
431        let decoder = Decoder::new(&data).unwrap();
432        assert!(!decoder.is_ultrahdr());
433        assert!(decoder.metadata().is_none());
434        assert!(decoder.gainmap_jpeg().is_none());
435        // Primary should be the whole file
436        assert!(decoder.primary_jpeg().is_some());
437    }
438
439    #[test]
440    fn test_decoder_borrows_data() {
441        let data = vec![0xFF, 0xD8, 0xFF, 0xD9];
442        let decoder = Decoder::new(&data).unwrap();
443        // The decoder borrows data, so primary_jpeg should be a subslice of our data
444        let primary = decoder.primary_jpeg().unwrap();
445        assert_eq!(primary.as_ptr(), data.as_ptr());
446    }
447
448    #[test]
449    fn test_decoder_empty_too_short() {
450        assert!(Decoder::new(&[]).is_err());
451        assert!(Decoder::new(&[0xFF]).is_err());
452        assert!(Decoder::new(&[0xFF, 0xD8]).is_err()); // Too short (< 4)
453    }
454
455    #[test]
456    fn test_decoder_icc_profile_none() {
457        let data = vec![0xFF, 0xD8, 0xFF, 0xD9];
458        let decoder = Decoder::new(&data).unwrap();
459        assert!(decoder.icc_profile().is_none());
460    }
461
462    #[test]
463    fn test_decoder_two_jpeg_fallback() {
464        // Two concatenated JPEGs — should find both via boundary scan
465        let data = vec![
466            0xFF, 0xD8, // SOI 1
467            0xFF, 0xD9, // EOI 1
468            0xFF, 0xD8, // SOI 2
469            0xFF, 0xD9, // EOI 2
470        ];
471        // Need to be >= 4 bytes total
472        let decoder = Decoder::new(&data).unwrap();
473        assert!(decoder.primary_jpeg().is_some());
474        assert!(decoder.gainmap_jpeg().is_some());
475    }
476
477    #[test]
478    fn test_find_xmp_in_segments_none() {
479        let segments: Vec<AppSegment> = vec![];
480        assert!(find_xmp_in_segments(&segments).is_none());
481    }
482
483    #[test]
484    fn test_decoder_xmp_without_hdrgm() {
485        // Build a fake JPEG with XMP APP1 containing valid XML but no hdrgm namespace
486        let xmp_ns = b"http://ns.adobe.com/xap/1.0/\0";
487        let xmp_body = b"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"><rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:creator>test</dc:creator></rdf:Description></rdf:RDF></x:xmpmeta>";
488        let segment_data_len = xmp_ns.len() + xmp_body.len();
489        let segment_len = (segment_data_len + 2) as u16; // +2 for length field itself
490
491        let mut data = Vec::new();
492        data.extend_from_slice(&[0xFF, 0xD8]); // SOI
493        data.push(0xFF);
494        data.push(0xE1); // APP1
495        data.extend_from_slice(&segment_len.to_be_bytes());
496        data.extend_from_slice(xmp_ns);
497        data.extend_from_slice(xmp_body);
498        data.extend_from_slice(&[0xFF, 0xD9]); // EOI
499
500        let decoder = Decoder::new(&data).unwrap();
501        assert!(!decoder.is_ultrahdr());
502        assert!(decoder.metadata().is_none());
503    }
504
505    #[test]
506    fn test_decoder_primary_jpeg_is_full_data_when_no_mpf() {
507        // Plain JPEG with no MPF — primary_jpeg() should return the entire data
508        let data = vec![
509            0xFF, 0xD8, // SOI
510            0xFF, 0xE0, 0x00, 0x07, // APP0 length 7
511            b'J', b'F', b'I', b'F', 0x00, // JFIF
512            0xFF, 0xD9, // EOI
513        ];
514        let decoder = Decoder::new(&data).unwrap();
515        let primary = decoder.primary_jpeg().unwrap();
516        assert_eq!(primary.len(), data.len());
517        assert_eq!(primary, &data[..]);
518    }
519
520    #[test]
521    fn test_decoder_gainmap_none_on_plain_jpeg() {
522        // Plain JPEG with no secondary images — gainmap_jpeg() should be None
523        let data = vec![
524            0xFF, 0xD8, // SOI
525            0xFF, 0xE0, 0x00, 0x07, // APP0 length 7
526            b'J', b'F', b'I', b'F', 0x00, // JFIF
527            0xFF, 0xD9, // EOI
528        ];
529        let decoder = Decoder::new(&data).unwrap();
530        assert!(decoder.gainmap_jpeg().is_none());
531    }
532
533    #[test]
534    fn test_find_xmp_in_segments_with_non_xmp() {
535        // APP1 segment that does NOT start with the XMP namespace (e.g., EXIF)
536        let segments = vec![AppSegment {
537            marker_num: 1,
538            data: b"Exif\0\0some_exif_data_here".to_vec(),
539            offset: 0,
540        }];
541        assert!(find_xmp_in_segments(&segments).is_none());
542
543        // APP1 with arbitrary data (not XMP, not EXIF)
544        let segments = vec![AppSegment {
545            marker_num: 1,
546            data: b"SomeRandomPrefix\0and_data".to_vec(),
547            offset: 0,
548        }];
549        assert!(find_xmp_in_segments(&segments).is_none());
550    }
551
552    #[test]
553    fn test_find_xmp_in_segments_with_xmp() {
554        let xmp_ns = b"http://ns.adobe.com/xap/1.0/\0";
555        let xmp_xml = b"<x:xmpmeta><rdf:RDF><rdf:Description/></rdf:RDF></x:xmpmeta>";
556
557        let mut segment_data = Vec::new();
558        segment_data.extend_from_slice(xmp_ns);
559        segment_data.extend_from_slice(xmp_xml);
560
561        let segments = vec![AppSegment {
562            marker_num: 1,
563            data: segment_data,
564            offset: 10,
565        }];
566
567        let result = find_xmp_in_segments(&segments);
568        assert!(result.is_some());
569        let xmp_str = result.unwrap();
570        assert!(xmp_str.contains("<x:xmpmeta>"));
571        assert!(xmp_str.contains("<rdf:RDF>"));
572    }
573}