Skip to main content

zenjxl_decoder/container/
gain_map.rs

1// Copyright (c) the JPEG XL Project Authors. All rights reserved.
2//
3// Use of this source code is governed by a BSD-style
4// license that can be found in the LICENSE file.
5
6//! Parser and serializer for the JPEG XL Gain Map bundle (`jhgm` box).
7//!
8//! The `jhgm` box contains an HDR gain map conforming to ISO 21496-1.
9//! In JXL, the base image is HDR and the gain map maps HDR to SDR
10//! (inverse direction from JPEG/AVIF).
11//!
12//! The gain map codestream is a bare JXL codestream (no container wrapper).
13//! The ISO 21496-1 metadata blob is stored as raw bytes for the caller
14//! to parse (e.g., via ultrahdr-core).
15
16use crate::error::{Error, Result};
17
18/// Current version of the gain map bundle format.
19const JHGM_VERSION: u8 = 0x00;
20
21/// Parsed JXL gain map bundle from a `jhgm` container box.
22///
23/// The bundle contains the ISO 21496-1 metadata, an optional JXL
24/// ColorEncoding, an optional Brotli-compressed ICC profile for the
25/// alternate rendition, and the bare JXL codestream of the gain map image.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct GainMapBundle {
28    /// ISO 21496-1 binary metadata blob (unparsed — caller parses with ultrahdr-core).
29    pub metadata: Vec<u8>,
30    /// JXL ColorEncoding for the gain map (optional, raw bytes — JXL-native bit-packed).
31    pub color_encoding: Option<Vec<u8>>,
32    /// Brotli-compressed ICC profile for alternate rendition (optional, not decompressed).
33    pub alt_icc_compressed: Option<Vec<u8>>,
34    /// Bare JXL codestream of the gain map image (no container wrapper).
35    pub gain_map_codestream: Vec<u8>,
36}
37
38impl GainMapBundle {
39    /// Parse a gain map bundle from the raw payload of a `jhgm` box.
40    ///
41    /// Wire format:
42    /// ```text
43    /// jhgm_version:            u8       // must be 0x00
44    /// gain_map_metadata_size:  u16 BE   // size of ISO 21496-1 metadata
45    /// gain_map_metadata:       [u8; N]  // ISO 21496-1 binary metadata
46    /// color_encoding_size:     u8       // 0 = absent; else byte count
47    /// color_encoding:          [u8; M]  // JXL ColorEncoding (optional)
48    /// alt_icc_size:            u32 BE   // size of Brotli-compressed ICC
49    /// alt_icc:                 [u8; K]  // Brotli-compressed ICC (optional)
50    /// gain_map:                [u8; *]  // remaining bytes = bare JXL codestream
51    /// ```
52    pub fn parse(data: &[u8]) -> Result<Self> {
53        let mut pos = 0;
54
55        // --- version ---
56        if data.is_empty() {
57            return Err(Error::InvalidGainMap("empty jhgm box".into()));
58        }
59        let version = data[pos];
60        pos += 1;
61        if version != JHGM_VERSION {
62            return Err(Error::InvalidGainMap(format!(
63                "unsupported jhgm version: {version:#04x}, expected {JHGM_VERSION:#04x}"
64            )));
65        }
66
67        // --- gain_map_metadata_size (u16 BE) ---
68        if pos + 2 > data.len() {
69            return Err(Error::InvalidGainMap(
70                "truncated: missing metadata size".into(),
71            ));
72        }
73        let metadata_size = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
74        pos += 2;
75
76        if pos + metadata_size > data.len() {
77            return Err(Error::InvalidGainMap(format!(
78                "truncated: metadata size {metadata_size} exceeds remaining {} bytes",
79                data.len() - pos
80            )));
81        }
82        let metadata = data[pos..pos + metadata_size].to_vec();
83        pos += metadata_size;
84
85        // --- color_encoding_size (u8) ---
86        if pos >= data.len() {
87            return Err(Error::InvalidGainMap(
88                "truncated: missing color_encoding_size".into(),
89            ));
90        }
91        let color_encoding_size = data[pos] as usize;
92        pos += 1;
93
94        let color_encoding = if color_encoding_size == 0 {
95            None
96        } else {
97            if pos + color_encoding_size > data.len() {
98                return Err(Error::InvalidGainMap(format!(
99                    "truncated: color_encoding size {color_encoding_size} exceeds remaining {} bytes",
100                    data.len() - pos
101                )));
102            }
103            let ce = data[pos..pos + color_encoding_size].to_vec();
104            pos += color_encoding_size;
105            Some(ce)
106        };
107
108        // --- alt_icc_size (u32 BE) ---
109        if pos + 4 > data.len() {
110            return Err(Error::InvalidGainMap(
111                "truncated: missing alt_icc_size".into(),
112            ));
113        }
114        let alt_icc_size =
115            u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
116        pos += 4;
117
118        let alt_icc_compressed = if alt_icc_size == 0 {
119            None
120        } else {
121            if pos + alt_icc_size > data.len() {
122                return Err(Error::InvalidGainMap(format!(
123                    "truncated: alt_icc size {alt_icc_size} exceeds remaining {} bytes",
124                    data.len() - pos
125                )));
126            }
127            let icc = data[pos..pos + alt_icc_size].to_vec();
128            pos += alt_icc_size;
129            Some(icc)
130        };
131
132        // --- gain_map codestream (remainder) ---
133        let gain_map_codestream = data[pos..].to_vec();
134
135        Ok(GainMapBundle {
136            metadata,
137            color_encoding,
138            alt_icc_compressed,
139            gain_map_codestream,
140        })
141    }
142
143    /// Serialize a gain map bundle to the wire format used inside a `jhgm` box.
144    ///
145    /// Returns the raw bytes that form the payload of a `jhgm` container box.
146    pub fn serialize(&self) -> Vec<u8> {
147        let metadata_size = self.metadata.len();
148        let color_encoding_size = self.color_encoding.as_ref().map_or(0, |v| v.len());
149        let alt_icc_size = self.alt_icc_compressed.as_ref().map_or(0, |v| v.len());
150
151        // Pre-allocate: version(1) + meta_size(2) + meta(N) + ce_size(1) + ce(M)
152        //             + icc_size(4) + icc(K) + codestream
153        let total = 1
154            + 2
155            + metadata_size
156            + 1
157            + color_encoding_size
158            + 4
159            + alt_icc_size
160            + self.gain_map_codestream.len();
161        let mut buf = Vec::with_capacity(total);
162
163        // version
164        buf.push(JHGM_VERSION);
165
166        // gain_map_metadata_size + metadata
167        // Truncate to u16::MAX if somehow larger (shouldn't happen in practice)
168        let meta_len = metadata_size.min(u16::MAX as usize) as u16;
169        buf.extend_from_slice(&meta_len.to_be_bytes());
170        buf.extend_from_slice(&self.metadata[..meta_len as usize]);
171
172        // color_encoding_size + color_encoding
173        // Truncate to u8::MAX if somehow larger
174        let ce_len = color_encoding_size.min(u8::MAX as usize) as u8;
175        buf.push(ce_len);
176        if let Some(ref ce) = self.color_encoding {
177            buf.extend_from_slice(&ce[..ce_len as usize]);
178        }
179
180        // alt_icc_size + alt_icc
181        let icc_len = alt_icc_size.min(u32::MAX as usize) as u32;
182        buf.extend_from_slice(&icc_len.to_be_bytes());
183        if let Some(ref icc) = self.alt_icc_compressed {
184            buf.extend_from_slice(&icc[..icc_len as usize]);
185        }
186
187        // gain_map codestream
188        buf.extend_from_slice(&self.gain_map_codestream);
189
190        buf
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    /// Build a minimal valid jhgm bundle by hand.
199    fn build_minimal_bundle(
200        metadata: &[u8],
201        color_encoding: Option<&[u8]>,
202        alt_icc: Option<&[u8]>,
203        gain_map: &[u8],
204    ) -> Vec<u8> {
205        let mut buf = Vec::new();
206        // version
207        buf.push(0x00);
208        // metadata size + metadata
209        buf.extend_from_slice(&(metadata.len() as u16).to_be_bytes());
210        buf.extend_from_slice(metadata);
211        // color_encoding_size + color_encoding
212        match color_encoding {
213            None => buf.push(0),
214            Some(ce) => {
215                buf.push(ce.len() as u8);
216                buf.extend_from_slice(ce);
217            }
218        }
219        // alt_icc_size + alt_icc
220        match alt_icc {
221            None => buf.extend_from_slice(&0u32.to_be_bytes()),
222            Some(icc) => {
223                buf.extend_from_slice(&(icc.len() as u32).to_be_bytes());
224                buf.extend_from_slice(icc);
225            }
226        }
227        // gain_map codestream
228        buf.extend_from_slice(gain_map);
229        buf
230    }
231
232    #[test]
233    fn test_parse_minimal_bundle() {
234        let metadata = b"\x01\x02\x03";
235        let gain_map = b"\xff\x0a"; // fake codestream signature bytes
236        let data = build_minimal_bundle(metadata, None, None, gain_map);
237
238        let bundle = GainMapBundle::parse(&data).unwrap();
239        assert_eq!(bundle.metadata, metadata);
240        assert!(bundle.color_encoding.is_none());
241        assert!(bundle.alt_icc_compressed.is_none());
242        assert_eq!(bundle.gain_map_codestream, gain_map);
243    }
244
245    #[test]
246    fn test_parse_full_bundle() {
247        let metadata = b"ISO21496-1 test metadata blob";
248        let color_encoding = b"\xAA\xBB\xCC\xDD";
249        let alt_icc = b"brotli-compressed-icc-data-here";
250        let gain_map = b"\xff\x0a\x00\x01\x02\x03\x04\x05";
251
252        let data = build_minimal_bundle(metadata, Some(color_encoding), Some(alt_icc), gain_map);
253
254        let bundle = GainMapBundle::parse(&data).unwrap();
255        assert_eq!(bundle.metadata.as_slice(), metadata.as_slice());
256        assert_eq!(
257            bundle.color_encoding.as_deref(),
258            Some(color_encoding.as_slice())
259        );
260        assert_eq!(
261            bundle.alt_icc_compressed.as_deref(),
262            Some(alt_icc.as_slice())
263        );
264        assert_eq!(bundle.gain_map_codestream.as_slice(), gain_map.as_slice());
265    }
266
267    #[test]
268    fn test_roundtrip_minimal() {
269        let original = GainMapBundle {
270            metadata: vec![0x10, 0x20, 0x30],
271            color_encoding: None,
272            alt_icc_compressed: None,
273            gain_map_codestream: vec![0xFF, 0x0A, 0x00],
274        };
275        let serialized = original.serialize();
276        let parsed = GainMapBundle::parse(&serialized).unwrap();
277        assert_eq!(original, parsed);
278    }
279
280    #[test]
281    fn test_roundtrip_full() {
282        let original = GainMapBundle {
283            metadata: vec![0x01; 100],
284            color_encoding: Some(vec![0xAA, 0xBB, 0xCC]),
285            alt_icc_compressed: Some(vec![0xDD; 256]),
286            gain_map_codestream: vec![0xFF, 0x0A, 0x00, 0x01, 0x02],
287        };
288        let serialized = original.serialize();
289        let parsed = GainMapBundle::parse(&serialized).unwrap();
290        assert_eq!(original, parsed);
291    }
292
293    #[test]
294    fn test_roundtrip_empty_gain_map() {
295        // Edge case: gain map codestream is empty (degenerate but parse should handle it)
296        let original = GainMapBundle {
297            metadata: vec![0x42],
298            color_encoding: None,
299            alt_icc_compressed: None,
300            gain_map_codestream: vec![],
301        };
302        let serialized = original.serialize();
303        let parsed = GainMapBundle::parse(&serialized).unwrap();
304        assert_eq!(original, parsed);
305    }
306
307    #[test]
308    fn test_error_empty_data() {
309        let result = GainMapBundle::parse(&[]);
310        assert!(result.is_err());
311        let err = result.unwrap_err().to_string();
312        assert!(err.contains("empty"), "unexpected error: {err}");
313    }
314
315    #[test]
316    fn test_error_wrong_version() {
317        let mut data = build_minimal_bundle(b"\x01", None, None, b"\xff");
318        data[0] = 0x01; // wrong version
319        let result = GainMapBundle::parse(&data);
320        assert!(result.is_err());
321        let err = result.unwrap_err().to_string();
322        assert!(err.contains("version"), "unexpected error: {err}");
323    }
324
325    #[test]
326    fn test_error_truncated_metadata_size() {
327        // Just version byte, no metadata size
328        let result = GainMapBundle::parse(&[0x00]);
329        assert!(result.is_err());
330        let err = result.unwrap_err().to_string();
331        assert!(err.contains("truncated"), "unexpected error: {err}");
332    }
333
334    #[test]
335    fn test_error_metadata_exceeds_data() {
336        // Version + metadata_size=1000 but only 2 bytes of actual metadata
337        let mut data = vec![0x00]; // version
338        data.extend_from_slice(&1000u16.to_be_bytes()); // metadata size = 1000
339        data.extend_from_slice(&[0x01, 0x02]); // only 2 bytes
340        let result = GainMapBundle::parse(&data);
341        assert!(result.is_err());
342        let err = result.unwrap_err().to_string();
343        assert!(err.contains("truncated"), "unexpected error: {err}");
344    }
345
346    #[test]
347    fn test_error_truncated_color_encoding_size() {
348        // Version + valid metadata but no color_encoding_size byte
349        let mut data = vec![0x00]; // version
350        data.extend_from_slice(&0u16.to_be_bytes()); // metadata size = 0
351        // missing color_encoding_size
352        let result = GainMapBundle::parse(&data);
353        assert!(result.is_err());
354    }
355
356    #[test]
357    fn test_error_truncated_color_encoding() {
358        // color_encoding_size says 10 bytes but only 3 available
359        let mut data = vec![0x00]; // version
360        data.extend_from_slice(&0u16.to_be_bytes()); // metadata size = 0
361        data.push(10); // color_encoding_size = 10
362        data.extend_from_slice(&[0x01, 0x02, 0x03]); // only 3 bytes
363        let result = GainMapBundle::parse(&data);
364        assert!(result.is_err());
365    }
366
367    #[test]
368    fn test_error_truncated_alt_icc_size() {
369        // Valid up to color_encoding but missing alt_icc_size
370        let mut data = vec![0x00]; // version
371        data.extend_from_slice(&0u16.to_be_bytes()); // metadata size = 0
372        data.push(0); // color_encoding_size = 0
373        // missing alt_icc_size (needs 4 bytes)
374        data.push(0x01); // only 1 byte
375        let result = GainMapBundle::parse(&data);
376        assert!(result.is_err());
377    }
378
379    #[test]
380    fn test_error_alt_icc_exceeds_data() {
381        let mut data = vec![0x00]; // version
382        data.extend_from_slice(&0u16.to_be_bytes()); // metadata size = 0
383        data.push(0); // color_encoding_size = 0
384        data.extend_from_slice(&500u32.to_be_bytes()); // alt_icc_size = 500
385        data.extend_from_slice(&[0xAA; 10]); // only 10 bytes
386        let result = GainMapBundle::parse(&data);
387        assert!(result.is_err());
388        let err = result.unwrap_err().to_string();
389        assert!(err.contains("truncated"), "unexpected error: {err}");
390    }
391
392    #[test]
393    fn test_large_metadata() {
394        // Metadata near u16::MAX
395        let metadata = vec![0x42; 60_000];
396        let gain_map = vec![0xFF, 0x0A];
397        let original = GainMapBundle {
398            metadata,
399            color_encoding: None,
400            alt_icc_compressed: None,
401            gain_map_codestream: gain_map,
402        };
403        let serialized = original.serialize();
404        let parsed = GainMapBundle::parse(&serialized).unwrap();
405        assert_eq!(original, parsed);
406    }
407
408    #[test]
409    fn test_large_alt_icc() {
410        // Large ICC profile
411        let alt_icc = vec![0xDD; 100_000];
412        let original = GainMapBundle {
413            metadata: vec![0x01],
414            color_encoding: None,
415            alt_icc_compressed: Some(alt_icc),
416            gain_map_codestream: vec![0xFF, 0x0A],
417        };
418        let serialized = original.serialize();
419        let parsed = GainMapBundle::parse(&serialized).unwrap();
420        assert_eq!(original, parsed);
421    }
422
423    /// Test that building a jhgm box (header + payload) and extracting the payload
424    /// round-trips correctly. This simulates what the container parser does.
425    #[test]
426    fn test_box_level_roundtrip() {
427        let bundle = GainMapBundle {
428            metadata: vec![0x01, 0x02],
429            color_encoding: Some(vec![0xAA]),
430            alt_icc_compressed: Some(vec![0xBB, 0xCC]),
431            gain_map_codestream: vec![0xFF, 0x0A, 0x00],
432        };
433
434        // Serialize the bundle payload
435        let payload = bundle.serialize();
436
437        // Build a complete jhgm box: [u32 BE size][b"jhgm"][payload]
438        let box_size = (8 + payload.len()) as u32;
439        let mut jhgm_box = Vec::new();
440        jhgm_box.extend_from_slice(&box_size.to_be_bytes());
441        jhgm_box.extend_from_slice(b"jhgm");
442        jhgm_box.extend_from_slice(&payload);
443
444        // Verify the box header
445        assert_eq!(&jhgm_box[4..8], b"jhgm");
446
447        // Extract payload from box and re-parse
448        let extracted_payload = &jhgm_box[8..];
449        let parsed = GainMapBundle::parse(extracted_payload).unwrap();
450        assert_eq!(bundle, parsed);
451    }
452}