Skip to main content

ultrahdr_rs/
encode.rs

1//! Ultra HDR encoder.
2
3#[cfg(feature = "_test-helpers")]
4use ultrahdr_core::color::tonemap::tonemap_image_to_srgb8;
5#[cfg(feature = "_test-helpers")]
6use ultrahdr_core::gainmap::compute::{GainMapConfig, compute_gainmap};
7use ultrahdr_core::metadata::{
8    iso21496::create_version_only_iso_app2,
9    mpf::create_mpf_header,
10    xmp::{build_gainmap_metadata_markers, create_xmp_app1_marker, generate_primary_xmp},
11};
12use ultrahdr_core::{ColorGamut, Error, GainMapEncodingFormat, GainMapMetadata, Result};
13#[cfg(feature = "_test-helpers")]
14use ultrahdr_core::{ColorTransfer, PixelFormat, Unstoppable};
15#[cfg(feature = "_test-helpers")]
16use ultrahdr_core::{GainMap, RawImage};
17
18use crate::jpeg::{
19    JpegSegment, create_icc_markers, get_icc_profile_for_gamut, insert_segment_after_soi,
20};
21
22/// Assemble an Ultra HDR JPEG from pre-encoded components.
23///
24/// Uses [`GainMapEncodingFormat::Both`] (XMP + ISO 21496-1) for maximum compatibility.
25/// For format control, use [`encode_ultrahdr_with_format`].
26pub fn encode_ultrahdr(
27    base_jpeg: &[u8],
28    gainmap_jpeg: &[u8],
29    metadata: &GainMapMetadata,
30    gamut: ColorGamut,
31) -> Result<Vec<u8>> {
32    encode_ultrahdr_with_format(
33        base_jpeg,
34        gainmap_jpeg,
35        metadata,
36        gamut,
37        GainMapEncodingFormat::Both,
38    )
39}
40
41/// Assemble an Ultra HDR JPEG from pre-encoded components with format control.
42///
43/// - `base_jpeg`: Pre-encoded SDR JPEG (the backwards-compatible base image)
44/// - `gainmap_jpeg`: Pre-encoded gain map JPEG (typically grayscale)
45/// - `metadata`: Gain map metadata describing how to apply the gain map
46/// - `gamut`: Color gamut of the base image (for ICC profile selection)
47/// - `format`: Which metadata format(s) to embed in the gain map JPEG
48pub fn encode_ultrahdr_with_format(
49    base_jpeg: &[u8],
50    gainmap_jpeg: &[u8],
51    metadata: &GainMapMetadata,
52    gamut: ColorGamut,
53    format: GainMapEncodingFormat,
54) -> Result<Vec<u8>> {
55    // Build metadata markers for the gain map JPEG (XMP and/or ISO 21496-1).
56    let metadata_markers = build_gainmap_metadata_markers(metadata, format);
57
58    // Inject metadata markers into the gain map JPEG after SOI.
59    let mut gainmap_final = gainmap_jpeg.to_vec();
60    // Insert in reverse order so each goes right after SOI
61    for marker in metadata_markers.iter().rev() {
62        let segment = JpegSegment {
63            marker: marker[1], // FF xx — take the marker byte
64            data: marker[4..].to_vec(),
65            offset: 0,
66        };
67        gainmap_final = insert_segment_after_soi(&gainmap_final, &segment)?;
68    }
69
70    // Generate primary XMP with container directory (points to gain map by size).
71    let primary_xmp = generate_primary_xmp(gainmap_final.len());
72    let primary_xmp_marker = create_xmp_app1_marker(&primary_xmp);
73
74    // Generate ICC profile
75    let icc_profile = get_icc_profile_for_gamut(gamut);
76    let icc_markers = create_icc_markers(&icc_profile);
77
78    // Insert primary XMP after SOI
79    let xmp_segment = JpegSegment {
80        marker: 0xE1,
81        data: primary_xmp_marker[4..].to_vec(),
82        offset: 0,
83    };
84    let mut primary = insert_segment_after_soi(base_jpeg, &xmp_segment)?;
85
86    // Insert version-only ISO 21496-1 APP2 into primary JPEG when ISO is enabled.
87    // This 4-byte block (min_version=0, writer_version=0) signals ISO 21496-1
88    // awareness. The actual gain map metadata lives in the secondary JPEG's APP2.
89    let include_iso = matches!(
90        format,
91        GainMapEncodingFormat::Iso21496 | GainMapEncodingFormat::Both
92    );
93    if include_iso {
94        let version_marker = create_version_only_iso_app2();
95        let iso_segment = JpegSegment {
96            marker: 0xE2,
97            data: version_marker[4..].to_vec(),
98            offset: 0,
99        };
100        primary = insert_segment_after_soi(&primary, &iso_segment)?;
101    }
102
103    // Insert ICC markers
104    for icc_marker in &icc_markers {
105        let icc_segment = JpegSegment {
106            marker: 0xE2,
107            data: icc_marker[4..].to_vec(),
108            offset: 0,
109        };
110        primary = insert_segment_after_soi(&primary, &icc_segment)?;
111    }
112
113    // Calculate sizes for MPF
114    let mpf_insert_pos = 2;
115    let mpf_estimate = create_mpf_header(0, 0, Some(mpf_insert_pos)).len();
116    let primary_with_mpf_len = primary.len() + mpf_estimate;
117
118    // Create MPF header
119    let mpf_header = create_mpf_header(
120        primary_with_mpf_len,
121        gainmap_final.len(),
122        Some(mpf_insert_pos),
123    );
124
125    // Insert MPF header
126    let mpf_segment = JpegSegment {
127        marker: 0xE2,
128        data: mpf_header[4..].to_vec(),
129        offset: 0,
130    };
131    let primary_final = insert_segment_after_soi(&primary, &mpf_segment)?;
132
133    // Concatenate primary and gain map
134    let mut result = primary_final;
135    result.extend_from_slice(&gainmap_final);
136
137    Ok(result)
138}
139
140/// Ultra HDR encoder.
141///
142/// For production use without a bundled JPEG codec, use [`encode_ultrahdr`] directly.
143///
144/// The builder methods that require a JPEG codec (`set_hdr_image`, `set_sdr_image`,
145/// `encode`) are only available in tests where zenjpeg is a dev-dependency.
146#[derive(Default)]
147pub struct Encoder {
148    #[cfg(feature = "_test-helpers")]
149    hdr_image: Option<RawImage>,
150    #[cfg(feature = "_test-helpers")]
151    sdr_image: Option<RawImage>,
152    compressed_sdr: Option<Vec<u8>>,
153    #[cfg(feature = "_test-helpers")]
154    existing_gainmap: Option<GainMap>,
155    existing_metadata: Option<GainMapMetadata>,
156    existing_gainmap_jpeg: Option<Vec<u8>>,
157    base_quality: u8,
158    gainmap_quality: u8,
159    gainmap_scale: u8,
160    target_display_peak: f32,
161    gain_map_min: f32,
162    #[cfg(feature = "_test-helpers")]
163    use_iso_metadata: bool,
164}
165
166impl Encoder {
167    /// Create a new encoder with default settings.
168    pub fn new() -> Self {
169        Self {
170            #[cfg(feature = "_test-helpers")]
171            hdr_image: None,
172            #[cfg(feature = "_test-helpers")]
173            sdr_image: None,
174            compressed_sdr: None,
175            #[cfg(feature = "_test-helpers")]
176            existing_gainmap: None,
177            existing_metadata: None,
178            existing_gainmap_jpeg: None,
179            base_quality: 90,
180            gainmap_quality: 85,
181            gainmap_scale: 4,
182            target_display_peak: 10000.0,
183            gain_map_min: 1.0,
184            #[cfg(feature = "_test-helpers")]
185            use_iso_metadata: true,
186        }
187    }
188
189    /// Set the HDR input image (test only - requires JPEG codec).
190    #[cfg(feature = "_test-helpers")]
191    pub fn set_hdr_image(&mut self, image: RawImage) -> &mut Self {
192        self.hdr_image = Some(image);
193        self
194    }
195
196    /// Set the SDR input image (test only - requires JPEG codec).
197    #[cfg(feature = "_test-helpers")]
198    pub fn set_sdr_image(&mut self, image: RawImage) -> &mut Self {
199        self.sdr_image = Some(image);
200        self
201    }
202
203    /// Set a pre-compressed SDR JPEG.
204    pub fn set_compressed_sdr(&mut self, jpeg: Vec<u8>) -> &mut Self {
205        self.compressed_sdr = Some(jpeg);
206        self
207    }
208
209    /// Alias for set_compressed_sdr.
210    pub fn set_base_jpeg(&mut self, jpeg: Vec<u8>) -> &mut Self {
211        self.set_compressed_sdr(jpeg)
212    }
213
214    /// Set an existing gain map and metadata (test only).
215    #[cfg(feature = "_test-helpers")]
216    pub fn set_existing_gainmap(
217        &mut self,
218        gainmap: GainMap,
219        metadata: GainMapMetadata,
220    ) -> &mut Self {
221        self.existing_gainmap = Some(gainmap);
222        self.existing_metadata = Some(metadata);
223        self
224    }
225
226    /// Clear any existing gain map (test only).
227    #[cfg(feature = "_test-helpers")]
228    pub fn clear_existing_gainmap(&mut self) -> &mut Self {
229        self.existing_gainmap = None;
230        self.existing_metadata = None;
231        self.existing_gainmap_jpeg = None;
232        self
233    }
234
235    /// Set an existing gain map as raw JPEG bytes and metadata.
236    pub fn set_existing_gainmap_jpeg(
237        &mut self,
238        jpeg: Vec<u8>,
239        metadata: GainMapMetadata,
240    ) -> &mut Self {
241        self.existing_gainmap_jpeg = Some(jpeg);
242        self.existing_metadata = Some(metadata);
243        self
244    }
245
246    /// Alias for set_existing_gainmap_jpeg.
247    pub fn set_gainmap_jpeg(&mut self, jpeg: Vec<u8>, metadata: GainMapMetadata) -> &mut Self {
248        self.set_existing_gainmap_jpeg(jpeg, metadata)
249    }
250
251    /// Check if an existing gain map is set (test only).
252    #[cfg(feature = "_test-helpers")]
253    pub fn has_existing_gainmap(&self) -> bool {
254        self.existing_gainmap.is_some() && self.existing_metadata.is_some()
255    }
256
257    /// Set JPEG quality for base and gain map images.
258    pub fn set_quality(&mut self, base: u8, gainmap: u8) -> &mut Self {
259        self.base_quality = base.clamp(1, 100);
260        self.gainmap_quality = gainmap.clamp(1, 100);
261        self
262    }
263
264    /// Set gain map downscale factor.
265    pub fn set_gainmap_scale(&mut self, scale: u8) -> &mut Self {
266        self.gainmap_scale = scale.clamp(1, 128);
267        self
268    }
269
270    /// Set target display peak brightness in nits.
271    pub fn set_target_display_peak(&mut self, nits: f32) -> &mut Self {
272        self.target_display_peak = nits.max(100.0);
273        self
274    }
275
276    /// Set minimum content boost.
277    pub fn set_min_content_boost(&mut self, boost: f32) -> &mut Self {
278        self.gain_map_min = boost.max(1.0);
279        self
280    }
281
282    /// Enable or disable ISO 21496-1 metadata (test only).
283    #[cfg(feature = "_test-helpers")]
284    pub fn set_use_iso_metadata(&mut self, use_iso: bool) -> &mut Self {
285        self.use_iso_metadata = use_iso;
286        self
287    }
288
289    /// Encode to Ultra HDR JPEG (test only - requires JPEG codec).
290    #[cfg(feature = "_test-helpers")]
291    pub fn encode(&self) -> Result<Vec<u8>> {
292        // Fast path: if we have raw gain map JPEG bytes, skip gain map processing
293        if let (Some(gainmap_jpeg), Some(metadata)) =
294            (&self.existing_gainmap_jpeg, &self.existing_metadata)
295        {
296            let (base_jpeg, gamut) = if let Some(ref compressed) = self.compressed_sdr {
297                (compressed.clone(), ColorGamut::Bt709)
298            } else if let Some(ref sdr_img) = self.sdr_image {
299                (self.encode_base_jpeg(sdr_img)?, sdr_img.gamut)
300            } else if let Some(ref hdr) = self.hdr_image {
301                let sdr_pixels = tonemap_image_to_srgb8(hdr, ColorGamut::Bt709)?;
302                let sdr = RawImage {
303                    width: hdr.width,
304                    height: hdr.height,
305                    stride: hdr.width * 4,
306                    data: sdr_pixels,
307                    format: PixelFormat::Rgba8,
308                    gamut: ColorGamut::Bt709,
309                    transfer: ColorTransfer::Srgb,
310                };
311                (self.encode_base_jpeg(&sdr)?, sdr.gamut)
312            } else {
313                return Err(Error::EncodeError(
314                    "Either HDR image, SDR image, or compressed SDR is required".into(),
315                ));
316            };
317
318            return encode_ultrahdr(&base_jpeg, gainmap_jpeg, metadata, gamut);
319        }
320
321        // Validate inputs
322        let hdr = self
323            .hdr_image
324            .as_ref()
325            .ok_or_else(|| Error::EncodeError("HDR image is required".into()))?;
326
327        // Generate or use provided SDR
328        let sdr = if let Some(ref sdr_img) = self.sdr_image {
329            sdr_img.clone()
330        } else {
331            let sdr_pixels = tonemap_image_to_srgb8(hdr, ColorGamut::Bt709)?;
332            RawImage {
333                width: hdr.width,
334                height: hdr.height,
335                stride: hdr.width * 4,
336                data: sdr_pixels,
337                format: PixelFormat::Rgba8,
338                gamut: ColorGamut::Bt709,
339                transfer: ColorTransfer::Srgb,
340            }
341        };
342
343        // Use existing gain map if provided, otherwise compute a new one
344        let (gainmap, metadata) =
345            if let (Some(gm), Some(meta)) = (&self.existing_gainmap, &self.existing_metadata) {
346                let expected_scale = self.gainmap_scale.max(1) as u32;
347                let expected_width = sdr.width.div_ceil(expected_scale);
348                let expected_height = sdr.height.div_ceil(expected_scale);
349
350                let width_ok =
351                    gm.width >= expected_width.saturating_sub(1) && gm.width <= expected_width + 1;
352                let height_ok = gm.height >= expected_height.saturating_sub(1)
353                    && gm.height <= expected_height + 1;
354
355                if width_ok && height_ok {
356                    (gm.clone(), meta.clone())
357                } else {
358                    self.compute_new_gainmap(hdr, &sdr)?
359                }
360            } else {
361                self.compute_new_gainmap(hdr, &sdr)?
362            };
363
364        // Encode base JPEG
365        let base_jpeg = if let Some(ref compressed) = self.compressed_sdr {
366            compressed.clone()
367        } else {
368            self.encode_base_jpeg(&sdr)?
369        };
370
371        // Encode gain map JPEG
372        let gainmap_jpeg = self.encode_gainmap_jpeg(&gainmap)?;
373
374        encode_ultrahdr(&base_jpeg, &gainmap_jpeg, &metadata, sdr.gamut)
375    }
376
377    /// Encode to Ultra HDR JPEG from pre-set JPEGs (production API).
378    pub fn encode_from_jpegs(&self) -> Result<Vec<u8>> {
379        let base_jpeg = self
380            .compressed_sdr
381            .as_ref()
382            .ok_or_else(|| Error::EncodeError("Base JPEG not set".into()))?;
383
384        let gainmap_jpeg = self
385            .existing_gainmap_jpeg
386            .as_ref()
387            .ok_or_else(|| Error::EncodeError("Gainmap JPEG not set".into()))?;
388
389        let metadata = self
390            .existing_metadata
391            .as_ref()
392            .ok_or_else(|| Error::EncodeError("Metadata not set".into()))?;
393
394        encode_ultrahdr(base_jpeg, gainmap_jpeg, metadata, ColorGamut::Bt709)
395    }
396
397    /// Compute a new gain map (test only).
398    #[cfg(feature = "_test-helpers")]
399    fn compute_new_gainmap(
400        &self,
401        hdr: &RawImage,
402        sdr: &RawImage,
403    ) -> Result<(GainMap, GainMapMetadata)> {
404        let config = GainMapConfig {
405            scale_factor: self.gainmap_scale,
406            gamma: 1.0,
407            multi_channel: false,
408            min_boost: self.gain_map_min,
409            max_boost: self.target_display_peak / 203.0,
410            base_offset: 1.0 / 64.0,
411            alternate_offset: 1.0 / 64.0,
412            base_hdr_headroom: 1.0, // linear: 1.0 = no boost → log2 = 0.0
413            alternate_hdr_headroom: self.target_display_peak / 203.0,
414        };
415
416        compute_gainmap(hdr, sdr, &config, Unstoppable)
417    }
418
419    /// Encode base SDR image to JPEG (test only).
420    #[cfg(feature = "_test-helpers")]
421    fn encode_base_jpeg(&self, sdr: &RawImage) -> Result<Vec<u8>> {
422        use zenjpeg::encoder::{ChromaSubsampling, EncoderConfig, PixelLayout, Unstoppable};
423
424        let (pixel_layout, data): (PixelLayout, std::borrow::Cow<[u8]>) = match sdr.format {
425            PixelFormat::Rgba8 => {
426                let rgb: Vec<u8> = sdr
427                    .data
428                    .chunks(4)
429                    .flat_map(|rgba| [rgba[0], rgba[1], rgba[2]])
430                    .collect();
431                (PixelLayout::Rgb8Srgb, std::borrow::Cow::Owned(rgb))
432            }
433            PixelFormat::Rgb8 => (
434                PixelLayout::Rgb8Srgb,
435                std::borrow::Cow::Borrowed(&sdr.data[..]),
436            ),
437            _ => {
438                return Err(Error::EncodeError(format!(
439                    "Unsupported SDR pixel format: {:?}",
440                    sdr.format
441                )));
442            }
443        };
444
445        let config = EncoderConfig::ycbcr(self.base_quality as f32, ChromaSubsampling::Quarter);
446        let mut enc = config
447            .encode_from_bytes(sdr.width, sdr.height, pixel_layout)
448            .map_err(|e| Error::JpegEncode(e.to_string()))?;
449        enc.push_packed(&data, Unstoppable)
450            .map_err(|e| Error::JpegEncode(e.to_string()))?;
451        enc.finish().map_err(|e| Error::JpegEncode(e.to_string()))
452    }
453
454    /// Encode gain map to JPEG (test only).
455    #[cfg(feature = "_test-helpers")]
456    fn encode_gainmap_jpeg(&self, gainmap: &GainMap) -> Result<Vec<u8>> {
457        use zenjpeg::encoder::{EncoderConfig, PixelLayout, Unstoppable};
458
459        let config = EncoderConfig::grayscale(self.gainmap_quality as f32);
460        let mut enc = config
461            .encode_from_bytes(gainmap.width, gainmap.height, PixelLayout::Gray8Srgb)
462            .map_err(|e| Error::JpegEncode(e.to_string()))?;
463        enc.push_packed(&gainmap.data, Unstoppable)
464            .map_err(|e| Error::JpegEncode(e.to_string()))?;
465        enc.finish().map_err(|e| Error::JpegEncode(e.to_string()))
466    }
467}
468
469#[cfg(all(test, feature = "_test-helpers"))]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn test_encoder_creation() {
475        let encoder = Encoder::new();
476        assert_eq!(encoder.base_quality, 90);
477        assert_eq!(encoder.gainmap_quality, 85);
478        assert_eq!(encoder.gainmap_scale, 4);
479    }
480
481    #[test]
482    fn test_encoder_builder() {
483        let mut encoder = Encoder::new();
484        encoder
485            .set_quality(95, 90)
486            .set_gainmap_scale(2)
487            .set_target_display_peak(4000.0);
488
489        assert_eq!(encoder.base_quality, 95);
490        assert_eq!(encoder.gainmap_quality, 90);
491        assert_eq!(encoder.gainmap_scale, 2);
492        assert_eq!(encoder.target_display_peak, 4000.0);
493    }
494
495    #[test]
496    fn test_encode_requires_hdr() {
497        let encoder = Encoder::new();
498        let result = encoder.encode();
499        assert!(result.is_err());
500    }
501
502    #[test]
503    fn test_existing_gainmap_methods() {
504        let mut encoder = Encoder::new();
505
506        assert!(!encoder.has_existing_gainmap());
507
508        let gainmap = GainMap::new(100, 100).unwrap();
509        let metadata = GainMapMetadata::new();
510        encoder.set_existing_gainmap(gainmap, metadata);
511        assert!(encoder.has_existing_gainmap());
512
513        encoder.clear_existing_gainmap();
514        assert!(!encoder.has_existing_gainmap());
515    }
516}