#hdr #image #gainmap

no-std ultrahdr-core

Core gain map math and metadata for Ultra HDR - no codec dependencies

3 unstable releases

0.2.0 Feb 21, 2026
0.1.1 Jan 24, 2026
0.1.0 Jan 23, 2026

#372 in Images


Used in 3 crates

Apache-2.0

325KB
6K SLoC

ultrahdr

CI codecov crates.io docs.rs MSRV License

Pure Rust implementation of Ultra HDR (gain map HDR) encoding and decoding.

Ultra HDR is a backward-compatible HDR image format that embeds a gain map in a standard JPEG, allowing HDR-capable displays to reconstruct the full HDR image while remaining viewable as SDR on legacy displays.

Crates

Crate Description
ultrahdr-rs Full encoder/decoder with zenjpeg JPEG codec
ultrahdr-core Pure math and metadata - no codec dependency, WASM-compatible

Features

  • Encode: Create Ultra HDR JPEGs from HDR images (with optional SDR input)
  • Decode: Extract and apply gain maps to reconstruct HDR content
  • Tone mapping: Automatic SDR generation from HDR-only input
  • Adaptive tonemapping: Learn tone curves from existing HDR/SDR pairs
  • Metadata: Full XMP (hdrgm namespace) and ISO 21496-1 support
  • Pure Rust: No C dependencies, uses zenjpeg for JPEG
  • WASM: ultrahdr-core compiles to WebAssembly

Comparison with C++ libultrahdr

Feature ultrahdr-rs C++ libultrahdr
Encoding
HDR + SDR → Ultra HDR JPEG Yes Yes
HDR-only (auto-tonemap SDR) Yes Yes
Adaptive tonemapping (learn curves) Yes No
Streaming encode (low memory) Yes No
Multi-channel gain map Yes Yes
Decoding
Ultra HDR → HDR reconstruct Yes Yes
Display boost parameter Yes Yes
Gain map extraction (raw JPEG) Yes Yes
Metadata
XMP (hdrgm namespace) Yes Yes
ISO 21496-1 binary Yes Yes
MPF (Multi-Picture Format) Yes Yes
Pixel Formats
RGBA 8-bit (SDR) Yes Yes
RGBA 32F / 16F (HDR) Yes Yes
P010 (10-bit YUV) Yes Yes
RGBA 1010102 (PQ/HLG) Yes Yes
Transfer Functions
sRGB Yes Yes
PQ (ST.2084) Yes Yes
HLG (BT.2100) Yes Yes
Color Gamuts
BT.709 / sRGB Yes Yes
Display P3 Yes Yes
BT.2100 / BT.2020 Yes Yes
Platform
Pure Rust (no C deps) Yes No (C++)
WASM support Yes (ultrahdr-core) No
no_std support Yes (ultrahdr-core) No
JPEG codec bundled Optional (zenjpeg) Yes (built-in)
Not Yet Implemented
JPEG-R (ISO 21496-1 container) No Yes
Editing API (in-place metadata update) No Yes
GPU acceleration No Yes (OpenGL)

Usage

Encoding

use ultrahdr_rs::{Encoder, RawImage, PixelFormat, ColorGamut, ColorTransfer};

// Create HDR image (linear float RGB, BT.2020 gamut)
let hdr_image = RawImage {
    width: 1920,
    height: 1080,
    format: PixelFormat::Rgba32F,
    gamut: ColorGamut::Bt2100,
    transfer: ColorTransfer::Linear,
    data: hdr_pixels,
    stride: 1920 * 16,
};

// Encode to Ultra HDR JPEG (SDR is auto-generated via tone mapping)
let ultrahdr_jpeg = Encoder::new()
    .set_hdr_image(hdr_image)
    .set_quality(90, 85)  // base quality, gainmap quality
    .set_gainmap_scale(4) // 1/4 resolution gain map
    .set_target_display_peak(1000.0) // nits
    .encode()?;

std::fs::write("output.jpg", &ultrahdr_jpeg)?;

Decoding

use ultrahdr_rs::Decoder;

let data = std::fs::read("ultrahdr.jpg")?;
let decoder = Decoder::new(&data)?;

if decoder.is_ultrahdr() {
    // Get HDR output (4x display boost)
    let hdr = decoder.decode_hdr(4.0)?;

    // Or just get SDR
    let sdr = decoder.decode_sdr()?;

    // Inspect metadata
    let metadata = decoder.metadata();
    println!("HDR capacity: {:.1}x", metadata.unwrap().hdr_capacity_max);
}

Adaptive Tonemapping (Preserve Artistic Intent)

When editing HDR content, use AdaptiveTonemapper to learn the original tone curve and reproduce it:

use ultrahdr_core::color::{AdaptiveTonemapper, FitConfig};

// Learn tone curve from original HDR/SDR pair
let tonemapper = AdaptiveTonemapper::fit(&original_hdr, &original_sdr)?;

// Apply to edited HDR - preserves the original artistic intent
let new_sdr = tonemapper.apply(&edited_hdr)?;

Supported Formats

Input (HDR)

  • Rgba32F - Linear float RGBA
  • Rgba16F - Half-float RGBA
  • P010 - 10-bit YUV (BT.2020)

Input (SDR)

  • Rgba8 - 8-bit sRGB RGBA
  • Rgb8 - 8-bit sRGB RGB

Output (HDR)

  • LinearFloat - Linear RGB float
  • Pq1010102 - PQ-encoded 10-bit packed
  • Srgb8 - Clipped to SDR range

Metadata Formats

Both XMP and ISO 21496-1 metadata are supported for maximum compatibility:

  • XMP: Adobe hdrgm namespace, embedded in APP1 marker
  • ISO 21496-1: Binary format with fractions, typically in APP2

Transfer Functions

  • sRGB (IEC 61966-2-1)
  • PQ/ST.2084 (HDR10)
  • HLG (ITU-R BT.2100)

Color Gamuts

  • BT.709 (sRGB)
  • Display P3
  • BT.2100/BT.2020

Pipeline Architecture

Understanding the correct sequencing is critical for both quality and memory efficiency.

┌─────────────────────────────────────────────────────────────────────────────┐
│                         STREAMING ENCODE PIPELINE                           │
│                        (4 MB peak vs 165 MB batch)                          │
└─────────────────────────────────────────────────────────────────────────────┘

  HDR Source                                              Output Files
  (AVIF/JXL/                                              ┌──────────────┐
   EXR/etc)                                               │ Ultra HDR    │
      │                                                   │ JPEG         │
      ▼                                                   │ ┌──────────┐ │
┌───────────┐     ┌─────────────────────────────────┐     │ │ SDR JPEG │ │
│ Streaming │     │      COLOR MANAGEMENT           │     │ │ (primary)│ │
│ Decoder   │────▶│  ┌─────────────────────────┐    │     │ ├──────────┤ │
│ (rows)    │     │  │ 1. Input Transform      │    │     │ │ Gain Map │ │
└───────────┘     │  │    PQ/HLG → Linear      │    │     │ │ (APP15)  │ │
                  │  │    BT.2100 → Working    │    │     │ ├──────────┤ │
   16 rows        │  │    (use moxcms)         │    │     │ │ XMP      │ │
   at a time      │  └───────────┬─────────────┘    │     │ │ Metadata │ │
                  │              │                  │     │ └──────────┘ │
                  │              ▼                  │     └──────────────┘
                  │  ┌─────────────────────────┐    │
                  │  │ 2. Linear Working Space │    │
                  │  │    (HDR, scene-referred)│    │
                  │  └───────────┬─────────────┘    │
                  │              │                  │
                  │      ┌───────┴───────┐         │
                  │      │               │         │
                  │      ▼               ▼         │
                  │  ┌───────┐    ┌────────────┐   │
                  │  │ Keep  │    │ 3. Tonemap │   │
                  │  │ HDR   │    │ HDRSDR  │   │
                  │  │ Linear│    │ (filmic/   │   │
                  │  └───┬───┘    │  adaptive) │   │
                  │      │        └─────┬──────┘   │
                  │      │              │          │
                  │      │              ▼          │
                  │      │    ┌─────────────────┐  │
                  │      │    │ 4. Output OETF  │  │
                  │      │    │    Linear→sRGB  │  │
                  │      │    │    (use moxcms) │  │
                  │      │    └────────┬────────┘  │
                  └──────│─────────────│───────────┘
                         │             │
                         ▼             ▼
               ┌─────────────────────────────────┐
               │        GAIN MAP ENCODER         │
               │  (RowEncoder / StreamEncoder)   │
               │                                 │
               │  Computes: gain = HDR/SDR       │
               │  Per-block, streaming output    │
               └────────────────┬────────────────┘
                                │
                    ┌───────────┴───────────┐
                    │                       │
                    ▼                       ▼
          ┌─────────────────┐    ┌─────────────────┐
          │  SDR JPEG       │    │  Gain Map JPEG  │
          │  Encoder        │    │  Encoder        │
          │  (streaming)    │    │  (streaming)    │
          │                 │    │                 │
          │  push_row()     │    │  push_row()     │
          └────────┬────────┘    └────────┬────────┘
                   │                      │
                   └──────────┬───────────┘
                              │
                              ▼
                   ┌─────────────────────┐
                   │   MPF Container     │
                   │   Assembly          │
                   │   + XMP Metadata    │
                   └─────────────────────┘

Color Management: Where moxcms Fits

┌────────────────────────────────────────────────────────────────────────┐
│                    COLOR MANAGEMENT STAGES                              │
│                                                                         │
│  ┌─────────────┐      ┌─────────────┐      ┌─────────────┐            │
│  │   INPUT     │      │   WORKING   │      │   OUTPUT    │            │
│  │   SPACE     │ ───▶ │   SPACE     │ ───▶ │   SPACE     │            │
│  └─────────────┘      └─────────────┘      └─────────────┘            │
│                                                                         │
│  Examples:            Always:               Examples:                   │
│  • PQ BT.2100        • Linear              • sRGB (SDR output)        │
│  • HLG BT.2100       • Scene-referred      • Display P3               │
│  • Linear BT.2020    • Wide gamut          • PQ (HDR output)          │
│                        (BT.2020 or         • Linear (gain map)        │
│                         AP0/ACES)                                      │
│                                                                         │
│  ┌──────────────────────────────────────────────────────────────────┐  │
│  │                         moxcms handles:                          │  │
│  │  • EOTF/OETF (PQ, HLG, sRGB transfer functions)                 │  │
│  │  • Chromatic adaptation (D65D50)                              │  │
│  │  • Gamut mapping (BT.2100 → sRGB with perceptual intent)        │  │
│  │  • ICC profile generation and parsing                            │  │
│  └──────────────────────────────────────────────────────────────────┘  │
│                                                                         │
│  ⚠️  CRITICAL: Tonemapping happens in LINEAR WORKING SPACE            │
│      Never tonemap PQ-encoded or sRGB-encoded values!                  │
│                                                                         │
└────────────────────────────────────────────────────────────────────────┘

Streaming Decode Pipeline

┌─────────────────────────────────────────────────────────────────────────┐
│                        STREAMING DECODE PIPELINE                        │
│                         (2 MB peak vs 166 MB)                           │
└─────────────────────────────────────────────────────────────────────────┘

  Ultra HDR JPEG
        │
        ▼
┌───────────────────┐
│ Parse MPF Header  │──────────────────────────────────────┐
│ Extract offsets   │                                      │
└────────┬──────────┘                                      │
         │                                                 │
    ┌────┴────┐                                           │
    │         │                                           │
    ▼         ▼                                           ▼
┌────────┐  ┌────────────┐                        ┌─────────────┐
│ SDR    │  │ Gain Map   │                        │ XMP/ISO     │
│ JPEG   │  │ JPEG       │                        │ Metadata    │
│ Decode │  │ Decode     │                        │ Parse       │
│(stream)│  │ (full or   │                        └──────┬──────┘
└───┬────┘  │  stream)   │                               │
    │       └─────┬──────┘                               │
    │             │                                      │
    │    ┌────────┴─────────────────────────────────────┐│
    │    │         GainMapMetadata                      ││
    │    │  • min/max_content_boost                     ││
    │    │  • gamma, offsets                            ││
    │    │  • hdr_capacity_min/max                      ││
    │    └────────┬─────────────────────────────────────┘│
    │             │                                      │
    ▼             ▼                                      │
┌─────────────────────────────────────┐                  │
│        HDR RECONSTRUCTION           │◀─────────────────┘
│        (RowDecoder/StreamDecoder)   │
│                                     │
│  For each pixel:                    │
│  1. Decode gain from gain map       │
│  2. Apply: HDR = (SDR + offset_sdr) │
│            × gain^weight            │
│            - offset_hdr             │
│  3. Bilinear upsample gain map      │
└──────────────────┬──────────────────┘
                   │
                   ▼
           ┌─────────────────┐
           │ OUTPUT TRANSFORM│
           │ (if needed)     │
           │ Linear → PQ/HLG │
           └────────┬────────┘
                    │
                    ▼
              HDR Output

Memory Comparison

┌────────────────────────────────────────────────────────────────────────┐
│                     MEMORY USAGE: 4K (3840×2160)                       │
├────────────────────────────────────────────────────────────────────────┤
│                                                                        │
│  BATCH ENCODE (full images in memory)                                  │
│  ═══════════════════════════════════                                   │
│                                                                        │
│  Stage              Memory                                             │
│  ─────              ──────                                             │
│  Decode HDR         132 MB  ████████████████████████████████████████  │
│  + Resize buffer    +33 MB  ██████████                                │
│  + SDR copy         +33 MB  ██████████                                │
│  + Gain map          +1 MB  ▌                                         │
│  ─────────────────────────                                             │
│  PEAK:              165 MB                                             │
│                                                                        │
│  STREAMING ENCODE (row buffers)                                        │
│  ══════════════════════════════                                        │
│                                                                        │
│  Component          Memory                                             │
│  ─────────          ──────                                             │
│  Decoder buffer      1.0 MB  ███                                       │
│  Resize buffer       0.5 MB  ██                                        │
│  Tonemap (in-place)  0.0 MB                                            │
│  RowEncoder buffers  1.0 MB  ███                                       │
│  JPEG encoders       1.5 MB  █████                                     │
│  ─────────────────────────                                             │
│  PEAK:               4.0 MB  (40× reduction!)                          │
│                                                                        │
└────────────────────────────────────────────────────────────────────────┘

Common Mistakes to Avoid

┌────────────────────────────────────────────────────────────────────────┐
│                          ❌ WRONG                                      │
├────────────────────────────────────────────────────────────────────────┤
│                                                                        │
│  1. Tonemapping PQ-encoded values                                      │
│     ✗ let sdr = tonemap(pq_pixel);  // PQ is perceptual, not linear!  │
│     ✓ let linear = pq_eotf(pq_pixel);                                 │
│       let sdr = tonemap(linear);                                       │
│                                                                        │
│  2. Computing gain map from sRGB (not linear)                          │
│     ✗ gain = srgb_hdr / srgb_sdr;  // Wrong! sRGB is nonlinear        │
│     ✓ gain = linear_hdr / linear_sdr;                                 │
│                                                                        │
│  3. Loading full image when streaming works                            │
│     ✗ let full_image = decoder.decode_all()?;  // 132 MB              │
│     ✓ for row in decoder.rows() { ... }        // 1 MB                │
│                                                                        │
│  4. Applying sRGB OETF before gain map computation                     │
│     ✗ let sdr = srgb_oetf(linear_sdr);                                │
│       compute_gainmap(hdr_linear, sdr);  // Mixing linear and sRGB!   │
│     ✓ compute_gainmap(hdr_linear, sdr_linear);                        │
│       let sdr_output = srgb_oetf(sdr_linear);                         │
│                                                                        │
│  5. Ignoring color gamut conversion                                    │
│     ✗ SDR in BT.2020 gamut (out-of-range values)                      │
│     ✓ Convert BT.2020 → sRGB with gamut mapping before SDR output     │
│                                                                        │
└────────────────────────────────────────────────────────────────────────┘

Correct Pipeline Order

┌────────────────────────────────────────────────────────────────────────┐
│                          ✓ CORRECT ORDER                               │
├────────────────────────────────────────────────────────────────────────┤
│                                                                        │
│  ENCODE:                                                               │
│  ═══════                                                               │
│  1. Decode HDR source (get encoded pixels)                             │
│  2. Apply EOTF (PQ/HLG → Linear)           ← moxcms                   │
│  3. Convert gamut to working space         ← moxcms                   │
│  4. Tonemap (linear HDR → linear SDR)      ← ultrahdr-core            │
│  5. Compute gain map (both in linear!)     ← ultrahdr-core            │
│  6. Convert SDR gamut to output space      ← moxcms                   │
│  7. Apply OETF (Linear → sRGB)             ← moxcms                   │
│  8. Encode SDR JPEG                        ← zenjpeg                  │
│  9. Encode gain map JPEG                   ← zenjpeg                  │
│  10. Assemble MPF container + XMP          ← ultrahdr-core            │
│                                                                        │
│  DECODE:                                                               │
│  ═══════                                                               │
│  1. Parse MPF, extract SDR + gain map JPEGs                            │
│  2. Parse XMP/ISO metadata                  ← ultrahdr-core           │
│  3. Decode SDR JPEG                         ← zenjpeg                 │
│  4. Decode gain map JPEG                    ← zenjpeg                 │
│  5. Apply EOTF to SDR (sRGB → Linear)      ← moxcms                   │
│  6. Apply gain map (in linear space!)       ← ultrahdr-core           │
│  7. Convert gamut if needed                 ← moxcms                   │
│  8. Apply OETF for output (Linear → PQ)    ← moxcms                   │
│                                                                        │
└────────────────────────────────────────────────────────────────────────┘

Streaming APIs (Low Memory)

For memory-constrained environments, ultrahdr-core provides streaming APIs that process images row-by-row:

use ultrahdr_core::gainmap::streaming::{RowDecoder, RowEncoder};
Type Direction Memory Use Case
RowDecoder SDR+gainmap→HDR Full gainmap in RAM Gainmap fits in memory
StreamDecoder SDR+gainmap→HDR 16-row ring buffer Parallel JPEG decode
RowEncoder HDR+SDR→gainmap Synchronized batches Same-rate inputs
StreamEncoder HDR+SDR→gainmap Independent buffers Parallel decode sources

Streaming Decode Example

use ultrahdr_core::gainmap::streaming::RowDecoder;
use ultrahdr_core::ColorGamut;

// Load gainmap fully, then stream SDR rows (linear f32)
let mut decoder = RowDecoder::new(
    gainmap, metadata, width, height, 4.0, ColorGamut::Bt709
)?;

// Process in 16-row batches (JPEG MCU alignment)
// Note: SDR input must be linear f32 RGB (3 floats per pixel)
for batch_start in (0..height).step_by(16) {
    let batch_height = 16.min(height - batch_start);
    let sdr_batch = jpeg_decoder.next_rows(batch_height); // linear f32
    let hdr_batch = decoder.process_sdr_rows(&sdr_batch, batch_height)?;
    write_output(&hdr_batch);
}

Memory Savings (4K image)

API Peak Memory
Full decode ~166 MB
Streaming (16 rows) ~2 MB

Streaming Tonemapper

StreamingTonemapper provides high-quality HDR→SDR tonemapping in a single streaming pass with local adaptation.

Semantics

┌────────────────────────────────────────────────────────────────────────┐
│                    STREAMING TONEMAPPER FLOW                           │
│                                                                         │
│   Input                    Internal                      Output         │
│   ──────                   ────────                      ──────         │
│                                                                         │
│   Row 0  ─────┐                                                        │
│   Row 1  ─────┤       ┌─────────────────────┐                          │
│   Row 2  ─────┼──────▶│   Lookahead Buffer  │                          │
│    ...   ─────┤       │   (ring buffer)     │                          │
│   Row N  ─────┘       │   Default: 64 rows  │                          │
│                       └──────────┬──────────┘                          │
│                                  │                                     │
│                                  ▼                                     │
│                       ┌─────────────────────┐                          │
│                       │  Local Adaptation   │                          │
│                       │  Grid (1/8 res)     │                          │
│                       │  • Per-cell stats   │                          │
│                       │  • Key (geo mean)   │                          │
│                       │  • White point      │                          │
│                       └──────────┬──────────┘                          │
│                                  │                                     │
│   ⚠️ OUTPUT LAG                  │                                     │
│   ═════════════                  ▼                                     │
│                       ┌─────────────────────┐       Row 0 ────▶       │
│   After pushing       │    Tonemap with     │       Row 1 ────▶       │
│   row 32, you get     │  local adaptation   │       Row 2 ────▶       │
│   row 0 out           │  • AgX highlights   │        ...              │
│                       │  • Shadow lift      │                          │
│   Lag = lookahead/2   └─────────────────────┘                          │
│       = 32 rows                                                        │
│                                                                         │
└────────────────────────────────────────────────────────────────────────┘

Key points:

  • Output lag: Rows come out lookahead_rows / 2 behind input (default: 32 rows)
  • Row order preserved: Output row indices match input, just delayed
  • Call finish(): Required to flush remaining rows after all input is pushed
  • Memory: ~6 MB for 4K (grid + row buffer)

API

use ultrahdr_core::color::{StreamingTonemapper, StreamingTonemapConfig};

// Configure (defaults shown)
let config = StreamingTonemapConfig {
    channels: 4,          // 3 for RGB, 4 for RGBA
    lookahead_rows: 64,   // Buffer size (affects quality & lag)
    cell_size: 8,         // Local adaptation grid: image_size / cell_size
    target_key: 0.18,     // Target mid-gray
    contrast: 1.1,        // Subtle contrast boost
    saturation: 0.95,     // Slight highlight desaturation
    shadow_lift: 0.02,    // Lift shadows slightly
    desat_threshold: 0.5, // Start desaturating at 50% of white
};

let mut tm = StreamingTonemapper::new(width, height, config)?;

// Push rows: (data, stride, num_rows)
// stride = elements between row starts (>= width * channels)
let outputs = tm.push_rows(&hdr_buffer, stride, num_rows)?;

// Process outputs as they become ready
for out in outputs {
    // out.row_index: which row this is (may not be sequential!)
    // out.sdr_linear: linear f32 SDR data, ready for OETF
    let srgb = tm.linear_to_srgb8(&out.sdr_linear);
    jpeg_encoder.push_row(&srgb)?;
}

// Flush remaining rows (REQUIRED!)
for out in tm.finish()? {
    let srgb = tm.linear_to_srgb8(&out.sdr_linear);
    jpeg_encoder.push_row(&srgb)?;
}

Output Ordering

Because of the lookahead buffer, outputs may not arrive in order during streaming. The row_index field tells you which row each output corresponds to.

// If you need sequential output (e.g., for JPEG encoder), buffer and sort:
let mut pending: BTreeMap<u32, Vec<f32>> = BTreeMap::new();
let mut next_to_emit = 0u32;

for out in tm.push_rows(&data, stride, rows)? {
    pending.insert(out.row_index, out.sdr_linear);

    // Emit any consecutive rows starting from next_to_emit
    while let Some(row) = pending.remove(&next_to_emit) {
        jpeg_encoder.push_row(&tm.linear_to_srgb8(&row))?;
        next_to_emit += 1;
    }
}

Memory Usage

Image Size Lookahead Grid Buffers Total
1920×1080 64 rows 0.5 MB 2 MB ~2.5 MB
3840×2160 64 rows 2 MB 4 MB ~6 MB
7680×4320 64 rows 8 MB 8 MB ~16 MB

Compare to full-frame tonemapping: 132 MB for 4K (entire image in RAM).

Using ultrahdr-core with zenjpeg Directly

For more control, use ultrahdr-core (math + metadata only) with zenjpeg for JPEG operations:

Encoding UltraHDR

use ultrahdr_core::{
    gainmap::compute::{compute_gainmap, GainMapConfig},
    metadata::xmp::generate_xmp,
    RawImage, PixelFormat, ColorGamut, ColorTransfer, Unstoppable,
};
use zenjpeg::encoder::{EncoderConfig, PixelLayout, ChromaSubsampling, Unstoppable as ZenjpegStop};

// 1. Compute gain map from HDR + SDR
let config = GainMapConfig::default();
let (gainmap, metadata) = compute_gainmap(&hdr_image, &sdr_image, &config, Unstoppable)?;

// 2. Encode gain map to JPEG
let gainmap_jpeg = {
    let cfg = EncoderConfig::grayscale(75.0);
    let mut enc = cfg.encode_from_bytes(gainmap.width, gainmap.height, PixelLayout::Gray8Srgb)?;
    enc.push_packed(&gainmap.data, ZenjpegStop)?;
    enc.finish()?
};

// 3. Generate XMP metadata
let xmp = generate_xmp(&metadata, gainmap_jpeg.len());

// 4. Encode UltraHDR with embedded gain map
let ultrahdr = {
    let cfg = EncoderConfig::ycbcr(90.0, ChromaSubsampling::Quarter)
        .add_gainmap(gainmap_jpeg);
    let mut enc = cfg.request()
        .xmp(xmp.as_bytes())
        .encode_from_bytes(width, height, PixelLayout::Rgb8Srgb)?;
    enc.push_packed(&sdr_rgb, ZenjpegStop)?;
    enc.finish()?
};

Decoding UltraHDR

use ultrahdr_core::{
    gainmap::apply::{apply_gainmap, HdrOutputFormat},
    metadata::xmp::parse_xmp,
    GainMap, RawImage, Unstoppable,
};
use zenjpeg::decoder::{Decoder, PreserveConfig};

// 1. Decode with metadata preservation
let decoded = Decoder::new()
    .preserve(PreserveConfig::default())
    .decode(&ultrahdr_jpeg, Unstoppable)?;

let extras = decoded.extras().expect("extras");

// 2. Parse XMP metadata
let xmp_str = extras.xmp().expect("XMP");
let (metadata, _) = parse_xmp(xmp_str)?;

// 3. Decode gain map JPEG
let gainmap_jpeg = extras.gainmap().expect("gainmap");
let gainmap_decoded = Decoder::new().decode(gainmap_jpeg, Unstoppable)?;

// 4. Build RawImage and GainMap structs
let sdr = RawImage::from_data(
    decoded.width(), decoded.height(),
    PixelFormat::Rgba8, ColorGamut::Bt709, ColorTransfer::Srgb,
    rgba_pixels,
)?;
let gainmap = GainMap {
    width: gainmap_decoded.width(),
    height: gainmap_decoded.height(),
    channels: 1,
    data: gainmap_decoded.pixels_u8().unwrap().to_vec(),
};

// 5. Apply gain map to reconstruct HDR
let hdr = apply_gainmap(&sdr, &gainmap, &metadata, 4.0, HdrOutputFormat::LinearFloat, Unstoppable)?;

Lossless Round-Trip (Edit SDR, Preserve Gain Map)

// Decode
let decoded = Decoder::new().preserve(PreserveConfig::default()).decode(&ultrahdr, Unstoppable)?;
let extras = decoded.extras().unwrap();

// Edit SDR pixels...
let edited_sdr: Vec<u8> = /* your edits */;

// Re-encode preserving XMP + gainmap
let encoder_segments = extras.to_encoder_segments();
let cfg = EncoderConfig::ycbcr(90.0, ChromaSubsampling::Quarter)
    .with_segments(encoder_segments);  // Preserves XMP + gainmap
let mut enc = cfg.encode_from_bytes(width, height, PixelLayout::Rgb8Srgb)?;
enc.push_packed(&edited_sdr, ZenjpegStop)?;
let re_encoded = enc.finish()?;

Cooperative Cancellation

Long-running operations accept an impl Stop parameter from the enough crate for cooperative cancellation:

use ultrahdr_core::{Unstoppable, Stop};
use enough::AtomicStop;

// Simple usage - no cancellation
let (gainmap, metadata) = compute_gainmap(&hdr, &sdr, &config, Unstoppable)?;

// With cancellation support
let stop = AtomicStop::new();
let stop_clone = stop.clone();
std::thread::spawn(move || {
    std::thread::sleep(Duration::from_secs(5));
    stop_clone.stop();
});
let result = compute_gainmap(&hdr, &sdr, &config, &stop);

Known Differences from libultrahdr

This implementation aims for compatibility with Google's libultrahdr reference implementation, but has the following known differences:

XMP Metadata Validation (ultrahdr-core)

Behavior libultrahdr This Implementation
BaseRenditionIsHDR="True" Rejected with error ⚠️ Accepted (should reject)
Required fields (Version, GainMapMax, HDRCapacityMax) All required ⚠️ Only checks if Version OR GainMapMax present
Unparseable field values Error ⚠️ Silently uses defaults

JPEG Boundary Detection

Behavior libultrahdr This Implementation
Primary method JpegScanner (SOI/EOI markers) MPF directory parsing
Fallback N/A SOI/EOI marker scanning
Marker-aware scanning Yes (skips marker payloads) ⚠️ Simple scan in ultrahdr-core, robust scan in zenjpeg
>2 images warning Yes No

Practical Impact

  • Files with BaseRenditionIsHDR="True" (rare) may decode incorrectly
  • Files with missing required XMP fields may use incorrect default values
  • Detection should work for all standard Ultra HDR files

Tracking

These differences are tracked for future fixes. Contributions welcome.

License

Apache-2.0

AI-Generated Code Notice

This library was developed with assistance from Claude (Anthropic). The implementation has been tested against reference Ultra HDR images and passes comprehensive unit tests. Not all code has been manually reviewed - please review critical paths before production use.

Dependencies

~2.3–4MB
~76K SLoC