ultrahdr_rs/
encode.rs

1//! Ultra HDR encoder.
2
3use ultrahdr_core::color::tonemap::tonemap_image_to_srgb8;
4use ultrahdr_core::gainmap::compute::{compute_gainmap, GainMapConfig};
5use ultrahdr_core::metadata::{
6    mpf::create_mpf_header,
7    xmp::{create_xmp_app1_marker, generate_xmp},
8};
9use ultrahdr_core::{
10    ColorGamut, ColorTransfer, Error, GainMap, GainMapMetadata, PixelFormat, RawImage, Result,
11    Unstoppable,
12};
13
14use crate::jpeg::{
15    create_icc_markers, get_icc_profile_for_gamut, insert_segment_after_soi, JpegSegment,
16};
17
18/// Ultra HDR encoder.
19///
20/// Supports multiple input modes:
21/// - HDR only: Automatically generates SDR via tone mapping
22/// - HDR + SDR: Uses provided SDR image
23/// - HDR + compressed SDR: Uses pre-encoded JPEG for base image
24/// - HDR + SDR + existing gain map: Reuses pre-computed gain map (for lossless round-trips)
25pub struct Encoder {
26    hdr_image: Option<RawImage>,
27    sdr_image: Option<RawImage>,
28    compressed_sdr: Option<Vec<u8>>,
29    existing_gainmap: Option<GainMap>,
30    existing_metadata: Option<GainMapMetadata>,
31    /// Raw gain map JPEG bytes (skips re-encode when set)
32    existing_gainmap_jpeg: Option<Vec<u8>>,
33    base_quality: u8,
34    gainmap_quality: u8,
35    gainmap_scale: u8,
36    target_display_peak: f32,
37    min_content_boost: f32,
38    use_iso_metadata: bool,
39}
40
41impl Default for Encoder {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl Encoder {
48    /// Create a new encoder with default settings.
49    pub fn new() -> Self {
50        Self {
51            hdr_image: None,
52            sdr_image: None,
53            compressed_sdr: None,
54            existing_gainmap: None,
55            existing_metadata: None,
56            existing_gainmap_jpeg: None,
57            base_quality: 90,
58            gainmap_quality: 85,
59            gainmap_scale: 4,
60            target_display_peak: 10000.0,
61            min_content_boost: 1.0,
62            use_iso_metadata: true,
63        }
64    }
65
66    /// Set the HDR input image (required).
67    pub fn set_hdr_image(&mut self, image: RawImage) -> &mut Self {
68        self.hdr_image = Some(image);
69        self
70    }
71
72    /// Set the SDR input image (optional).
73    ///
74    /// If not provided, SDR will be generated via tone mapping.
75    pub fn set_sdr_image(&mut self, image: RawImage) -> &mut Self {
76        self.sdr_image = Some(image);
77        self
78    }
79
80    /// Set a pre-compressed SDR JPEG (optional).
81    ///
82    /// If provided, this JPEG will be used as the base image instead of
83    /// compressing the SDR image.
84    pub fn set_compressed_sdr(&mut self, jpeg: Vec<u8>) -> &mut Self {
85        self.compressed_sdr = Some(jpeg);
86        self
87    }
88
89    /// Set an existing gain map and metadata (optional).
90    ///
91    /// If provided, this gain map will be used instead of computing a new one.
92    /// This enables lossless UltraHDR round-trips (decode → process → encode)
93    /// when the SDR dimensions haven't changed.
94    ///
95    /// Note: The caller is responsible for ensuring the gain map is appropriate
96    /// for the SDR image. If the SDR dimensions have changed (e.g., after resize),
97    /// the gain map should be invalidated and recomputed.
98    pub fn set_existing_gainmap(
99        &mut self,
100        gainmap: GainMap,
101        metadata: GainMapMetadata,
102    ) -> &mut Self {
103        self.existing_gainmap = Some(gainmap);
104        self.existing_metadata = Some(metadata);
105        self
106    }
107
108    /// Clear any existing gain map, forcing recomputation.
109    pub fn clear_existing_gainmap(&mut self) -> &mut Self {
110        self.existing_gainmap = None;
111        self.existing_metadata = None;
112        self.existing_gainmap_jpeg = None;
113        self
114    }
115
116    /// Set an existing gain map as raw JPEG bytes and metadata (optional).
117    ///
118    /// This is an alternative to `set_existing_gainmap` that skips the
119    /// JPEG re-encoding step entirely. Useful when the original gain map
120    /// JPEG is available (e.g., from a decoded UltraHDR image).
121    ///
122    /// When set, this takes precedence over `existing_gainmap`.
123    pub fn set_existing_gainmap_jpeg(
124        &mut self,
125        jpeg: Vec<u8>,
126        metadata: GainMapMetadata,
127    ) -> &mut Self {
128        self.existing_gainmap_jpeg = Some(jpeg);
129        self.existing_metadata = Some(metadata);
130        self
131    }
132
133    /// Check if an existing gain map is set.
134    pub fn has_existing_gainmap(&self) -> bool {
135        self.existing_gainmap.is_some() && self.existing_metadata.is_some()
136    }
137
138    /// Set JPEG quality for base and gain map images.
139    ///
140    /// Quality ranges from 1-100. Default: base=90, gainmap=85.
141    pub fn set_quality(&mut self, base: u8, gainmap: u8) -> &mut Self {
142        self.base_quality = base.clamp(1, 100);
143        self.gainmap_quality = gainmap.clamp(1, 100);
144        self
145    }
146
147    /// Set gain map downscale factor.
148    ///
149    /// The gain map is typically smaller than the base image.
150    /// Factor of 4 means gain map is 1/4 the width and height.
151    /// Default: 4. Range: 1-128.
152    pub fn set_gainmap_scale(&mut self, scale: u8) -> &mut Self {
153        self.gainmap_scale = scale.clamp(1, 128);
154        self
155    }
156
157    /// Set target display peak brightness in nits.
158    ///
159    /// Default: 10000.0 (HDR10 max).
160    pub fn set_target_display_peak(&mut self, nits: f32) -> &mut Self {
161        self.target_display_peak = nits.max(100.0);
162        self
163    }
164
165    /// Set minimum content boost.
166    ///
167    /// Default: 1.0 (no boost at minimum).
168    pub fn set_min_content_boost(&mut self, boost: f32) -> &mut Self {
169        self.min_content_boost = boost.max(1.0);
170        self
171    }
172
173    /// Enable or disable ISO 21496-1 metadata.
174    ///
175    /// Default: true (include both XMP and ISO metadata).
176    pub fn set_use_iso_metadata(&mut self, use_iso: bool) -> &mut Self {
177        self.use_iso_metadata = use_iso;
178        self
179    }
180
181    /// Encode to Ultra HDR JPEG.
182    pub fn encode(&self) -> Result<Vec<u8>> {
183        // Fast path: if we have raw gain map JPEG bytes, skip gain map processing entirely
184        if let (Some(ref gainmap_jpeg), Some(ref metadata)) =
185            (&self.existing_gainmap_jpeg, &self.existing_metadata)
186        {
187            // We still need base JPEG - use compressed_sdr or generate from SDR/HDR
188            let (base_jpeg, gamut) = if let Some(ref compressed) = self.compressed_sdr {
189                (compressed.clone(), ColorGamut::Bt709)
190            } else if let Some(ref sdr_img) = self.sdr_image {
191                (self.encode_base_jpeg(sdr_img)?, sdr_img.gamut)
192            } else if let Some(ref hdr) = self.hdr_image {
193                // Generate SDR via tone mapping
194                let sdr_pixels = tonemap_image_to_srgb8(hdr, ColorGamut::Bt709);
195                let sdr = RawImage {
196                    width: hdr.width,
197                    height: hdr.height,
198                    stride: hdr.width * 4,
199                    data: sdr_pixels,
200                    format: PixelFormat::Rgba8,
201                    gamut: ColorGamut::Bt709,
202                    transfer: ColorTransfer::Srgb,
203                };
204                (self.encode_base_jpeg(&sdr)?, sdr.gamut)
205            } else {
206                return Err(Error::EncodeError(
207                    "Either HDR image, SDR image, or compressed SDR is required".into(),
208                ));
209            };
210
211            return self.create_ultrahdr_jpeg(&base_jpeg, gainmap_jpeg, metadata, gamut);
212        }
213
214        // Validate inputs
215        let hdr = self
216            .hdr_image
217            .as_ref()
218            .ok_or_else(|| Error::EncodeError("HDR image is required".into()))?;
219
220        // Generate or use provided SDR
221        let sdr = if let Some(ref sdr_img) = self.sdr_image {
222            sdr_img.clone()
223        } else {
224            // Generate SDR via tone mapping
225            let sdr_pixels = tonemap_image_to_srgb8(hdr, ColorGamut::Bt709);
226            RawImage {
227                width: hdr.width,
228                height: hdr.height,
229                stride: hdr.width * 4,
230                data: sdr_pixels,
231                format: PixelFormat::Rgba8,
232                gamut: ColorGamut::Bt709,
233                transfer: ColorTransfer::Srgb,
234            }
235        };
236
237        // Use existing gain map if provided, otherwise compute a new one
238        let (gainmap, metadata) =
239            if let (Some(gm), Some(meta)) = (&self.existing_gainmap, &self.existing_metadata) {
240                // Validate that the existing gain map dimensions are appropriate
241                // The gain map should be roughly (sdr_width / scale) x (sdr_height / scale)
242                let expected_scale = self.gainmap_scale.max(1) as u32;
243                let expected_width = sdr.width.div_ceil(expected_scale);
244                let expected_height = sdr.height.div_ceil(expected_scale);
245
246                // Allow some tolerance for rounding differences
247                let width_ok =
248                    gm.width >= expected_width.saturating_sub(1) && gm.width <= expected_width + 1;
249                let height_ok = gm.height >= expected_height.saturating_sub(1)
250                    && gm.height <= expected_height + 1;
251
252                if width_ok && height_ok {
253                    // Reuse existing gain map
254                    (gm.clone(), meta.clone())
255                } else {
256                    // Dimensions don't match - recompute
257                    self.compute_new_gainmap(hdr, &sdr)?
258                }
259            } else {
260                // No existing gain map - compute new one
261                self.compute_new_gainmap(hdr, &sdr)?
262            };
263
264        // Encode base JPEG
265        let base_jpeg = if let Some(ref compressed) = self.compressed_sdr {
266            compressed.clone()
267        } else {
268            self.encode_base_jpeg(&sdr)?
269        };
270
271        // Encode gain map JPEG
272        let gainmap_jpeg = self.encode_gainmap_jpeg(&gainmap)?;
273
274        // Create Ultra HDR structure
275        self.create_ultrahdr_jpeg(&base_jpeg, &gainmap_jpeg, &metadata, sdr.gamut)
276    }
277
278    /// Compute a new gain map from HDR and SDR images.
279    fn compute_new_gainmap(
280        &self,
281        hdr: &RawImage,
282        sdr: &RawImage,
283    ) -> Result<(GainMap, GainMapMetadata)> {
284        let config = GainMapConfig {
285            scale_factor: self.gainmap_scale,
286            gamma: 1.0,
287            multi_channel: false,
288            min_content_boost: self.min_content_boost,
289            max_content_boost: self.target_display_peak / 203.0, // SDR white = 203 nits
290            offset_sdr: 1.0 / 64.0,
291            offset_hdr: 1.0 / 64.0,
292            hdr_capacity_min: 1.0,
293            hdr_capacity_max: self.target_display_peak / 203.0,
294        };
295
296        compute_gainmap(hdr, sdr, &config, Unstoppable)
297    }
298
299    /// Encode base SDR image to JPEG.
300    fn encode_base_jpeg(&self, sdr: &RawImage) -> Result<Vec<u8>> {
301        use jpegli::encoder::{ChromaSubsampling, EncoderConfig, PixelLayout, Unstoppable};
302
303        let (pixel_layout, data): (PixelLayout, std::borrow::Cow<[u8]>) = match sdr.format {
304            PixelFormat::Rgba8 => {
305                // Convert RGBA to RGB for JPEG
306                let rgb: Vec<u8> = sdr
307                    .data
308                    .chunks(4)
309                    .flat_map(|rgba| [rgba[0], rgba[1], rgba[2]])
310                    .collect();
311                (PixelLayout::Rgb8Srgb, std::borrow::Cow::Owned(rgb))
312            }
313            PixelFormat::Rgb8 => (
314                PixelLayout::Rgb8Srgb,
315                std::borrow::Cow::Borrowed(&sdr.data[..]),
316            ),
317            _ => {
318                return Err(Error::EncodeError(format!(
319                    "Unsupported SDR pixel format: {:?}",
320                    sdr.format
321                )))
322            }
323        };
324
325        let config = EncoderConfig::ycbcr(self.base_quality as f32, ChromaSubsampling::Quarter);
326        let mut enc = config
327            .encode_from_bytes(sdr.width, sdr.height, pixel_layout)
328            .map_err(|e| Error::JpegEncode(e.to_string()))?;
329        enc.push_packed(&data, Unstoppable)
330            .map_err(|e| Error::JpegEncode(e.to_string()))?;
331        enc.finish().map_err(|e| Error::JpegEncode(e.to_string()))
332    }
333
334    /// Encode gain map to JPEG.
335    fn encode_gainmap_jpeg(&self, gainmap: &crate::GainMap) -> Result<Vec<u8>> {
336        use jpegli::encoder::{EncoderConfig, PixelLayout, Unstoppable};
337
338        let config = EncoderConfig::grayscale(self.gainmap_quality as f32);
339        let mut enc = config
340            .encode_from_bytes(gainmap.width, gainmap.height, PixelLayout::Gray8Srgb)
341            .map_err(|e| Error::JpegEncode(e.to_string()))?;
342        enc.push_packed(&gainmap.data, Unstoppable)
343            .map_err(|e| Error::JpegEncode(e.to_string()))?;
344        enc.finish().map_err(|e| Error::JpegEncode(e.to_string()))
345    }
346
347    /// Create final Ultra HDR JPEG structure.
348    fn create_ultrahdr_jpeg(
349        &self,
350        base_jpeg: &[u8],
351        gainmap_jpeg: &[u8],
352        metadata: &GainMapMetadata,
353        gamut: ColorGamut,
354    ) -> Result<Vec<u8>> {
355        // Generate XMP
356        let xmp = generate_xmp(metadata, gainmap_jpeg.len());
357        let xmp_marker = create_xmp_app1_marker(&xmp);
358
359        // Generate ICC profile
360        let icc_profile = get_icc_profile_for_gamut(gamut);
361        let icc_markers = create_icc_markers(&icc_profile);
362
363        // Insert XMP after SOI
364        let xmp_segment = JpegSegment {
365            marker: 0xE1,
366            data: xmp_marker[4..].to_vec(), // Skip FF E1 and length
367            offset: 0,
368        };
369        let mut primary = insert_segment_after_soi(base_jpeg, &xmp_segment)?;
370
371        // Insert ICC markers
372        for icc_marker in &icc_markers {
373            let icc_segment = JpegSegment {
374                marker: 0xE2,
375                data: icc_marker[4..].to_vec(),
376                offset: 0,
377            };
378            primary = insert_segment_after_soi(&primary, &icc_segment)?;
379        }
380
381        // Calculate sizes for MPF
382        // MPF header will be inserted, so we need to account for it
383        let mpf_estimate = create_mpf_header(0, 0).len();
384        let primary_with_mpf_len = primary.len() + mpf_estimate;
385
386        // Create MPF header
387        let mpf_header = create_mpf_header(primary_with_mpf_len, gainmap_jpeg.len());
388
389        // Insert MPF header
390        let mpf_segment = JpegSegment {
391            marker: 0xE2,
392            data: mpf_header[4..].to_vec(),
393            offset: 0,
394        };
395        let primary_final = insert_segment_after_soi(&primary, &mpf_segment)?;
396
397        // Concatenate primary and gain map
398        let mut result = primary_final;
399        result.extend_from_slice(gainmap_jpeg);
400
401        Ok(result)
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_encoder_creation() {
411        let encoder = Encoder::new();
412        assert_eq!(encoder.base_quality, 90);
413        assert_eq!(encoder.gainmap_quality, 85);
414        assert_eq!(encoder.gainmap_scale, 4);
415    }
416
417    #[test]
418    fn test_encoder_builder() {
419        let mut encoder = Encoder::new();
420        encoder
421            .set_quality(95, 90)
422            .set_gainmap_scale(2)
423            .set_target_display_peak(4000.0);
424
425        assert_eq!(encoder.base_quality, 95);
426        assert_eq!(encoder.gainmap_quality, 90);
427        assert_eq!(encoder.gainmap_scale, 2);
428        assert_eq!(encoder.target_display_peak, 4000.0);
429    }
430
431    #[test]
432    fn test_encode_requires_hdr() {
433        let encoder = Encoder::new();
434        let result = encoder.encode();
435        assert!(result.is_err());
436    }
437
438    #[test]
439    fn test_existing_gainmap_methods() {
440        let mut encoder = Encoder::new();
441
442        // Initially no existing gain map
443        assert!(!encoder.has_existing_gainmap());
444
445        // Set existing gain map
446        let gainmap = GainMap::new(100, 100).unwrap();
447        let metadata = GainMapMetadata::new();
448        encoder.set_existing_gainmap(gainmap, metadata);
449        assert!(encoder.has_existing_gainmap());
450
451        // Clear it
452        encoder.clear_existing_gainmap();
453        assert!(!encoder.has_existing_gainmap());
454    }
455}