diff --git a/Cargo.lock b/Cargo.lock
index 107301c1..e8037a6f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3027,6 +3027,7 @@ dependencies = [
name = "lightcraft-codecs"
version = "0.5.0"
dependencies = [
+ "avif-serialize",
"bytemuck",
"image",
"image-webp",
@@ -3040,6 +3041,7 @@ dependencies = [
"moxcms 0.9.1",
"png",
"proptest",
+ "rav1e",
"ravif",
"rayon",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index e85ef3f8..58e5383b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -68,6 +68,9 @@ moxcms = { version = "0.9", default-features = false, features = ["lut"] }
jxl-oxide = { version = "0.12", default-features = false }
zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only)
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
+# HDR AVIF (PQ): ravif fixes the sRGB transfer curve, so HDR files drive its rav1e + avif-serialize directly
+rav1e = { version = "0.8.1", default-features = false, features = ["threading"] }
+avif-serialize = "0.8.6"
# gpu (crates/gpu; native backends only, no GL: everything stays pure Rust)
# SAM 3 inference (lightcraft-segment). 0.9.2: later releases pull `tokenizers` with the
diff --git a/README.md b/README.md
index 450d9740..217092fb 100644
--- a/README.md
+++ b/README.md
@@ -307,7 +307,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI masks and denoise:** subject and sky selection are classical heuristics;
- - **HDR, video and the Classic Print / Book / Map modules.**
+ - **on-screen HDR display, video and the Classic Print / Book / Map modules.**
- **What's next:** see [where we're going](ROADMAP.md#where-were-going).
| Area | Status |
@@ -323,7 +323,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | ๐ก ยท CR3, compressed ORF and compressed SRW decode โฌ |
| Detail: sharpening, luminance + colour noise reduction | โ
ยท AI Denoise, Super Resolution โฌ |
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | โ
ยท content-aware fill, spot pin editing ๐ง |
-| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | โ
ยท HDR export โฌ |
+| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | โ
|
+| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG, PQ AVIF or 32-bit float TIFF | โ
ยท HDR display โฌ |
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | โ
|
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | โ
|
| MCP server (headless or live app, persistent libraries), CLI, control channel | โ
|
@@ -331,7 +332,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | โ
ยท camera lens profiles (our own) โฌ |
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama โ DNG | โ
|
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | โ
ยท WebGPU in the browser ๐ง |
-| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | โฌ (see [roadmap](ROADMAP.md#where-were-going)) |
+| AI: segmentation masks, AI denoise, super resolution, faces; video | โฌ (see [roadmap](ROADMAP.md#where-were-going)) |
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | โ
ยท WebGPU, Safari/Firefox testing ๐ง |
โ
works today ยท ๐ง in progress ยท โฌ not started
diff --git a/ROADMAP.md b/ROADMAP.md
index 12b273a1..dad5a432 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -28,7 +28,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| **AI & computational** | ~15โ20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34โ36 / 233โ252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7โ4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic), paginated contact-sheet PDF export (A4/Letter, layout/captions, current edits, 150 dpi sRGB) | **Map view, Book, full Print module, Slideshow module, Web, publish services**: contact sheets work, custom print packages and printer colour management remain missing |
-| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
+| **HDR & video** | ~25% | HDR editing (headroom limit, SDR rendition, Visualize HDR); gain map JPEG, PQ AVIF and float TIFF export | On-screen HDR display, HLG, HDR settings from crs XMP; video play/trim/edit/export |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
### By kind of user
@@ -94,7 +94,7 @@ yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-
| M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6โ10 | ๐ง (sharpening, luminance/colour NR โ
; AI Denoise ๐ก (Bayer, optional weights; policy/fidelity open); Raw Details, Super Resolution โฌ) |
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6โ10 | ๐ง (heal, clone, auto source, visualize spots, red/pet eye โ
; PatchMatch remove โฌ) |
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5โ8 | โ
|
-| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6โ10 | ๐ง (all formats incl. DNG/original, sizing, presets, background jobs โ
; JXL encode, HDR export โฌ) |
+| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6โ10 | ๐ง (all formats incl. DNG/original, sizing, presets, background jobs, HDR gain map JPEG / PQ AVIF โ
; JXL encode โฌ) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20โ35 | ๐ง (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed โ
; **camera colour calibration** ๐ง (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF โฌ) |
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20โ40 | โฌ |
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10โ15 | โ
|
diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml
index 5c310cf0..4c93a89a 100644
--- a/crates/codecs/Cargo.toml
+++ b/crates/codecs/Cargo.toml
@@ -30,6 +30,8 @@ jxl-oxide = { workspace = true, optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
ravif = { workspace = true, optional = true }
+rav1e = { workspace = true, optional = true }
+avif-serialize = { workspace = true, optional = true }
[features]
default = ["parallel", "jxl", "avif"]
@@ -40,7 +42,7 @@ jxl = ["dep:jxl-oxide"]
# PhotoCraft); `cargo xtask ci` clippies and tests it (`--features heif`), releases don't ship it yet.
heif = ["dep:lightcraft-heif"]
## AVIF encoding via ravif/rav1e (pure Rust, BSD). Native targets only; a no-op on wasm32.
-avif = ["dep:ravif"]
+avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"]
[dev-dependencies]
proptest = { workspace = true }
diff --git a/crates/codecs/README.md b/crates/codecs/README.md
index a4fecef6..e1998c49 100644
--- a/crates/codecs/README.md
+++ b/crates/codecs/README.md
@@ -23,6 +23,12 @@ encode_png(&EncodeImage::new(w, h, 4, Samples::U16(&px)), &meta)?;
encode_tiff(&EncodeImage::new(w, h, 3, Samples::F32(&px)), TiffCompression::Deflate, &meta)?;
encode_webp_lossless(&img, &meta)?;
encode_avif(&img, 70, 6, &meta)?; // native + feature `avif`
+
+// HDR gain map JPEG (ISO 21496-1): SDR + HDR renditions โ base + gain map, and back
+let (map, gm) = gainmap::compute(&sdr_linear, &hdr_linear, w, h, luma, &GainMapOptions::default())?;
+gainmap::encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&base8)), &map, &gm, 90, ChromaSubsampling::S444, &meta)?;
+let found = gainmap::read_jpeg(&bytes); // ours, cameras', Lightroom's
+gainmap::apply(&mut base_linear, w, h, &found.gain, &found.meta, 1.0)?;
```
Thumbnail fallback decoding limits the source to 64 million pixels by default.
@@ -46,8 +52,9 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
| GIF / BMP | yes (first GIF frame) | โ | via `image` |
| PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | โ | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported |
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | โ | enum colour โ rendered straight to linear Rec.2020; ICC โ our ICC path; orientation applied by the decoder (reported as 1) |
-| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8-bit sRGB, EXIF; no ICC in the muxer |
+| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_pq` writes 10-bit BT.2020 PQ (SDR white at 203 cd/mยฒ, CICP `nclx` + `clli`) |
| HEIC/HEIF | yes with feature `heif` (lightcraft-heif: heic-rs + libheif-matching colour; 4:2:0/mono, 8โ10-bit, grids, irot/imir/clap, ICC/nclx/VUI, EXIF/XMP, thumbnail item) | โ | 4:2:2/4:4:4 (Canon/Sony HIF) unsupported |
+| Gain map JPEG (HDR) | gain map found and decoded (`gainmap::read_jpeg`): ISO 21496-1 metadata, else Adobe `hdrgm` XMP | yes (`gainmap::encode_jpeg`) | CIPA DC-007 MPF index (gain map typed `0x050000`), ISO 21496-1 APP2 on both images, `hdrgm` + Container XMP for Android/Chrome, Apple `HDRGainMap`/`HDRToneMap` XMP; gain maps are never used as thumbnails |
## Colour
diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs
index 59622073..c002abd9 100644
--- a/crates/codecs/src/encode.rs
+++ b/crates/codecs/src/encode.rs
@@ -79,6 +79,46 @@ pub enum ChromaSubsampling {
const XMP_NS: &[u8] = b"http://ns.adobe.com/xap/1.0/\0";
+/// The APP segments [`encode_jpeg`] writes for `meta` (marker byte, payload): JFIF density, EXIF,
+/// XMP and the ICC profile in chunks, in that order.
+pub(crate) fn jpeg_app_segments(meta: &EncodeMeta) -> Result)>> {
+ let mut segs: Vec<(u8, Vec)> = Vec::new();
+ let ppi = meta.ppi.filter(|p| *p > 0).map_or([0u8, 0, 1, 0, 1], |p| {
+ let [hi, lo] = p.to_be_bytes();
+ [1, hi, lo, hi, lo]
+ });
+ segs.push((0xE0, [&b"JFIF\0\x01\x02"[..], &ppi, &[0, 0]].concat()));
+ if let Some(exif) = meta.exif {
+ if exif.len() > 65_527 {
+ return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into()));
+ }
+ segs.push((0xE1, [&b"Exif\0\0"[..], exif].concat()));
+ }
+ if let Some(xmp) = meta.xmp {
+ segs.push((0xE1, xmp_segment(xmp)?));
+ }
+ if let Some(icc) = meta.icc {
+ // ICC.1 Annex B.4: "ICC_PROFILE\0", 1-based chunk number, chunk count
+ let chunks: Vec<&[u8]> = icc.chunks(65_519).collect();
+ if chunks.len() > 255 {
+ return Err(Error::Encode("ICC profile too large for JPEG".into()));
+ }
+ for (i, c) in chunks.iter().enumerate() {
+ segs.push((0xE2, [&b"ICC_PROFILE\0"[..], &[i as u8 + 1, chunks.len() as u8], c].concat()));
+ }
+ }
+ Ok(segs)
+}
+
+/// An APP1 XMP payload (namespace + packet), refusing packets larger than one segment.
+pub(crate) fn xmp_segment(xmp: &str) -> Result> {
+ let seg = [XMP_NS, xmp.as_bytes()].concat();
+ if seg.len() > 65_533 {
+ return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into()));
+ }
+ Ok(seg)
+}
+
/// Encode baseline JPEG (8-bit; alpha is dropped). `quality` 1..=100.
pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsampling, meta: &EncodeMeta) -> Result> {
img.validate()?;
@@ -97,35 +137,7 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin
};
if subsampling != ChromaSubsampling::S422 {
// Parallel encoder (restart-interval bands on all cores).
- let mut segs: Vec<(u8, Vec)> = Vec::new();
- let ppi = meta.ppi.filter(|p| *p > 0).map_or([0u8, 0, 1, 0, 1], |p| {
- let [hi, lo] = p.to_be_bytes();
- [1, hi, lo, hi, lo]
- });
- segs.push((0xE0, [&b"JFIF\0\x01\x02"[..], &ppi, &[0, 0]].concat()));
- if let Some(exif) = meta.exif {
- if exif.len() > 65_527 {
- return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into()));
- }
- segs.push((0xE1, [&b"Exif\0\0"[..], exif].concat()));
- }
- if let Some(xmp) = meta.xmp {
- let seg = [XMP_NS, xmp.as_bytes()].concat();
- if seg.len() > 65_533 {
- return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into()));
- }
- segs.push((0xE1, seg));
- }
- if let Some(icc) = meta.icc {
- // ICC.1 Annex B.4: "ICC_PROFILE\0", 1-based chunk number, chunk count
- let chunks: Vec<&[u8]> = icc.chunks(65_519).collect();
- if chunks.len() > 255 {
- return Err(Error::Encode("ICC profile too large for JPEG".into()));
- }
- for (i, c) in chunks.iter().enumerate() {
- segs.push((0xE2, [&b"ICC_PROFILE\0"[..], &[i as u8 + 1, chunks.len() as u8], c].concat()));
- }
- }
+ let segs = jpeg_app_segments(meta)?;
let ch = if img.channels == 2 { 1 } else { img.channels as usize };
// `data` is already gray for 2-channel input (see above)
return Ok(crate::jpeg_par::encode(&data, img.width as usize, img.height as usize, ch, quality, subsampling, &segs));
@@ -351,6 +363,141 @@ pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta)
}
}
+/// Absolute luminance of SDR white in an HDR file (ITU-R BT.2408 reference white), cd/mยฒ.
+pub const HDR_REFERENCE_WHITE_NITS: f32 = 203.0;
+
+/// SMPTE ST 2084 (PQ) inverse EOTF: luminance in cd/mยฒ โ signal 0..1.
+pub fn pq_encode(nits: f32) -> f32 {
+ const M1: f32 = 2610.0 / 16384.0;
+ const M2: f32 = 2523.0 / 4096.0 * 128.0;
+ const C1: f32 = 3424.0 / 4096.0;
+ const C2: f32 = 2413.0 / 4096.0 * 32.0;
+ const C3: f32 = 2392.0 / 4096.0 * 32.0;
+ let y = (nits / 10_000.0).clamp(0.0, 1.0).powf(M1);
+ ((C1 + C2 * y) / (1.0 + C3 * y)).powf(M2)
+}
+
+/// SMPTE ST 2084 (PQ) EOTF: signal 0..1 โ luminance in cd/mยฒ.
+pub fn pq_decode(e: f32) -> f32 {
+ const M1: f32 = 2610.0 / 16384.0;
+ const M2: f32 = 2523.0 / 4096.0 * 128.0;
+ const C1: f32 = 3424.0 / 4096.0;
+ const C2: f32 = 2413.0 / 4096.0 * 32.0;
+ const C3: f32 = 2392.0 / 4096.0 * 32.0;
+ let p = e.clamp(0.0, 1.0).powf(1.0 / M2);
+ ((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1) * 10_000.0
+}
+
+/// Encode an HDR AVIF: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0
+/// (`width ร height ร 3`). Written as 10-bit 4:4:4 BT.2020 non-constant-luminance YCbCr with the
+/// PQ transfer curve, SDR white at [`HDR_REFERENCE_WHITE_NITS`], and a content light level box.
+/// `quality` 1..=100, `speed` 1..=10. Native targets with the `avif` feature only.
+pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result> {
+ let (w, h) = (width as usize, height as usize);
+ let n = w.checked_mul(h).ok_or_else(|| Error::Encode("image too large".into()))?;
+ if n == 0 || n.checked_mul(3).is_none_or(|need| rgb.len() < need) {
+ return Err(Error::Encode("HDR AVIF: sample buffer too short".into()));
+ }
+ #[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
+ {
+ use rav1e::prelude::*;
+ const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; // BT.2020 NCL
+ let mut max_nits = 0f32;
+ let mut sum_nits = 0f64;
+ let planes: Vec<[u16; 3]> = rgb
+ .as_chunks::<3>()
+ .0
+ .iter()
+ .take(n)
+ .map(|c| {
+ let nits = c.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * HDR_REFERENCE_WHITE_NITS);
+ let y_nits = K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2];
+ max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2]));
+ sum_nits += y_nits as f64;
+ let [r, g, b] = nits.map(pq_encode);
+ let y = K[0] * r + K[1] * g + K[2] * b;
+ let cb = (b - y) / (2.0 * (1.0 - K[2])) + 0.5;
+ let cr = (r - y) / (2.0 * (1.0 - K[0])) + 0.5;
+ [y, cb, cr].map(|v| (v * 1023.0).round().clamp(0.0, 1023.0) as u16)
+ })
+ .collect();
+ let q = quality.clamp(1, 100) as f32 / 100.0;
+ // ravif's quality โ quantizer curve, so qualities match the SDR AVIF path
+ let x = if q >= 0.82 {
+ (1.0 - q) * 2.6
+ } else if q > 0.25 {
+ 1.0 - 0.125 - q * 0.5
+ } else {
+ 1.0 - q
+ };
+ let quantizer = (x * 255.0).round() as usize;
+ let mut ec = EncoderConfig::with_speed_preset(speed.clamp(1, 10));
+ ec.width = w;
+ ec.height = h;
+ ec.time_base = Rational::new(1, 1);
+ ec.bit_depth = 10;
+ ec.chroma_sampling = ChromaSampling::Cs444;
+ ec.pixel_range = PixelRange::Full;
+ ec.color_description = Some(ColorDescription {
+ color_primaries: ColorPrimaries::BT2020,
+ transfer_characteristics: TransferCharacteristics::SMPTE2084,
+ matrix_coefficients: MatrixCoefficients::BT2020NCL,
+ });
+ let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u16, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u16);
+ ec.content_light = Some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 });
+ ec.still_picture = true;
+ ec.quantizer = quantizer;
+ ec.min_quantizer = quantizer.min(255) as u8;
+ ec.speed_settings.multiref = false;
+ ec.speed_settings.rdo_lookahead_frames = 1;
+ ec.speed_settings.scene_detection_mode = SceneDetectionSpeed::None;
+ let e = |e: &dyn std::fmt::Display| Error::Encode(format!("AV1: {e}"));
+ let mut ctx: Context = Config::new().with_encoder_config(ec).new_context().map_err(|x| e(&x))?;
+ let mut frame = ctx.new_frame();
+ {
+ let mut it = planes.iter();
+ let mut fp = frame.planes.iter_mut();
+ let (Some(py), Some(pu), Some(pv)) = (fp.next(), fp.next(), fp.next()) else {
+ return Err(Error::Encode("AV1: frame has no planes".into()));
+ };
+ let (mut sy, mut su, mut sv) = (py.mut_slice(Default::default()), pu.mut_slice(Default::default()), pv.mut_slice(Default::default()));
+ for ((ry, ru), rv) in sy.rows_iter_mut().zip(su.rows_iter_mut()).zip(sv.rows_iter_mut()).take(h) {
+ for ((y, u), v) in ry.iter_mut().zip(ru.iter_mut()).zip(rv.iter_mut()).take(w) {
+ let Some(p) = it.next() else { break };
+ (*y, *u, *v) = (p[0], p[1], p[2]);
+ }
+ }
+ }
+ ctx.send_frame(frame).map_err(|x| e(&x))?;
+ ctx.flush();
+ let mut av1 = Vec::new();
+ loop {
+ match ctx.receive_packet() {
+ Ok(mut p) if p.frame_type == FrameType::KEY => av1.append(&mut p.data),
+ Ok(_) => {}
+ Err(EncoderStatus::Encoded | EncoderStatus::LimitReached) => break,
+ Err(x) => return Err(e(&x)),
+ }
+ }
+ use avif_serialize::constants as c;
+ let mut a = avif_serialize::Aviffy::new();
+ a.set_matrix_coefficients(c::MatrixCoefficients::Bt2020Ncl)
+ .set_transfer_characteristics(c::TransferCharacteristics::Smpte2084)
+ .set_color_primaries(c::ColorPrimaries::Bt2020)
+ .set_full_color_range(true)
+ .set_content_light_level(cll.0, cll.1);
+ if let Some(exif) = meta.exif {
+ a.set_exif(exif.to_vec());
+ }
+ Ok(a.to_vec(&av1, None, width, height, 10))
+ }
+ #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))]
+ {
+ let _ = (quality, speed, meta);
+ Err(Error::Encode("AVIF encoding is not available in this build".into()))
+ }
+}
+
/// 16-bit RGB(A) โ 10-bit BT.601 full-range YCbCr planes (the matrix `ravif` uses for 8-bit input).
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result> {
diff --git a/crates/codecs/src/gainmap.rs b/crates/codecs/src/gainmap.rs
new file mode 100644
index 00000000..13c3c0e5
--- /dev/null
+++ b/crates/codecs/src/gainmap.rs
@@ -0,0 +1,834 @@
+//! Gain map HDR images (ISO 21496-1): an SDR base image plus a gain map that a decoder uses to
+//! rebuild the HDR rendition, so the file looks right everywhere and lights up on HDR displays.
+//!
+//! - [`compute`] measures a gain map from an SDR and an HDR rendition of the same picture (linear
+//! light, same primaries). Measure it against the base *as written* (decoded from its 8-bit
+//! codes), so quantisation in the base is handed back by the gain map.
+//! - [`apply`] rebuilds the HDR rendition from a base and its gain map.
+//! - [`encode_jpeg`] writes a gain map JPEG: the base with an MPF index (CIPA DC-007), the ISO
+//! 21496-1 marker and Adobe/Google container XMP, followed by the gain map JPEG carrying the ISO
+//! 21496-1 metadata, the Adobe `hdrgm` XMP (Android, Chrome) and Apple's description of the same
+//! map (what survives an iMessage send).
+//! - [`read_jpeg`] finds and decodes the gain map of a gain map JPEG (ours, cameras, Lightroom),
+//! from its ISO 21496-1 metadata or, failing that, its `hdrgm` XMP.
+//!
+//! All gains are log2. A decoder recovers `log2(hdr + offset_hdr) โ log2(sdr + offset_sdr)` as
+//! `min + (max โ min) ยท (code / 255)^(1 / gamma)`, scaled by a weight in 0..1 that depends on how
+//! much headroom the display has between `base_headroom` and `alternate_headroom`.
+
+use crate::encode::{EncodeImage, EncodeMeta, Samples, jpeg_app_segments, xmp_segment};
+use crate::{ChromaSubsampling, Error, Format, Result, jpeg, jpeg_par};
+
+/// The ISO 21496-1 namespace that opens its APP2 payloads (NUL terminated).
+pub const ISO_URN: &[u8] = b"urn:iso:std:iso:ts:21496:-1\0";
+
+/// Fixed denominator for the metadata's rationals: one value per setting, byte-identical output.
+const DENOMINATOR: u32 = 1_000_000;
+
+/// Gain map parameters (ISO 21496-1 / Adobe `hdrgm`). Per-channel arrays repeat the first value
+/// when the map is single-channel.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct GainMapMeta {
+ /// Three gain channels (R, G, B) instead of one luminance gain.
+ pub multichannel: bool,
+ /// The gain applies in the base image's colour space.
+ pub use_base_colour_space: bool,
+ /// log2 headroom at which the base is shown as is (0: an SDR base).
+ pub base_headroom: f32,
+ /// log2 headroom at which the gain map applies in full.
+ pub alternate_headroom: f32,
+ pub min: [f32; 3],
+ pub max: [f32; 3],
+ pub gamma: [f32; 3],
+ pub base_offset: [f32; 3],
+ pub alternate_offset: [f32; 3],
+}
+
+impl Default for GainMapMeta {
+ fn default() -> Self {
+ GainMapMeta {
+ multichannel: false,
+ use_base_colour_space: true,
+ base_headroom: 0.0,
+ alternate_headroom: 1.0,
+ min: [0.0; 3],
+ max: [1.0; 3],
+ gamma: [1.0; 3],
+ base_offset: [1.0 / 64.0; 3],
+ alternate_offset: [1.0 / 64.0; 3],
+ }
+ }
+}
+
+impl GainMapMeta {
+ fn channels(&self) -> usize {
+ if self.multichannel { 3 } else { 1 }
+ }
+
+ /// Usable by [`apply`]: finite, positive gammas, max โฅ min, headrooms in order.
+ pub fn is_valid(&self) -> bool {
+ let fin = |a: &[f32; 3]| a.iter().all(|v| v.is_finite());
+ fin(&self.min)
+ && fin(&self.max)
+ && fin(&self.gamma)
+ && fin(&self.base_offset)
+ && fin(&self.alternate_offset)
+ && self.gamma.iter().all(|g| *g > 0.0)
+ && self.min.iter().zip(&self.max).all(|(a, b)| b >= a)
+ && self.base_headroom.is_finite()
+ && self.alternate_headroom.is_finite()
+ && self.alternate_headroom > self.base_headroom
+ }
+
+ /// The ISO 21496-1 APP2 payload of the gain map image, URN included.
+ pub fn to_iso(&self) -> Vec {
+ let mut p = ISO_URN.to_vec();
+ p.extend_from_slice(&0u16.to_be_bytes()); // minimum_version: readers gate on this
+ p.extend_from_slice(&0u16.to_be_bytes()); // writer_version
+ let mut flags = 0u8;
+ if self.multichannel {
+ flags |= FLAG_MULTICHANNEL;
+ }
+ if self.use_base_colour_space {
+ flags |= FLAG_BASE_COLOUR_SPACE;
+ }
+ p.push(flags);
+ let unsigned = |p: &mut Vec, v: f32| {
+ let n = (v as f64 * DENOMINATOR as f64).round().clamp(0.0, u32::MAX as f64) as u32;
+ p.extend_from_slice(&n.to_be_bytes());
+ p.extend_from_slice(&DENOMINATOR.to_be_bytes());
+ };
+ let signed = |p: &mut Vec, v: f32| {
+ let n = (v as f64 * DENOMINATOR as f64).round().clamp(i32::MIN as f64, i32::MAX as f64) as i32;
+ p.extend_from_slice(&n.to_be_bytes());
+ p.extend_from_slice(&DENOMINATOR.to_be_bytes());
+ };
+ unsigned(&mut p, self.base_headroom.max(0.0));
+ unsigned(&mut p, self.alternate_headroom.max(0.0));
+ for c in 0..self.channels() {
+ signed(&mut p, self.min[c]);
+ signed(&mut p, self.max[c]);
+ unsigned(&mut p, self.gamma[c]);
+ signed(&mut p, self.base_offset[c]);
+ signed(&mut p, self.alternate_offset[c]);
+ }
+ p
+ }
+
+ /// Parse an ISO 21496-1 payload (the bytes after [`ISO_URN`]). `None` for the versions-only
+ /// marker a primary image carries, unknown versions, a base that is the HDR rendition, or
+ /// values no decoder could use.
+ pub fn from_iso(p: &[u8]) -> Option {
+ let mut r = Reader { b: p, at: 0 };
+ let min_version = r.u16()?;
+ let _writer_version = r.u16()?;
+ if min_version != 0 {
+ return None;
+ }
+ let flags = r.u8()?;
+ if flags & FLAG_BACKWARD != 0 {
+ return None;
+ }
+ let multichannel = flags & FLAG_MULTICHANNEL != 0;
+ let common = flags & FLAG_COMMON_DENOMINATOR != 0;
+ let common_d = if common { r.u32()? } else { 0 };
+ let unsigned = |r: &mut Reader| -> Option {
+ let n = r.u32()?;
+ let d = if common { common_d } else { r.u32()? };
+ (d != 0).then(|| (n as f64 / d as f64) as f32)
+ };
+ let signed = |r: &mut Reader| -> Option {
+ let n = r.u32()? as i32;
+ let d = if common { common_d } else { r.u32()? };
+ (d != 0).then(|| (n as f64 / d as f64) as f32)
+ };
+ let mut m = GainMapMeta {
+ multichannel,
+ use_base_colour_space: flags & FLAG_BASE_COLOUR_SPACE != 0,
+ base_headroom: unsigned(&mut r)?,
+ alternate_headroom: unsigned(&mut r)?,
+ ..GainMapMeta::default()
+ };
+ for c in 0..m.channels() {
+ m.min[c] = signed(&mut r)?;
+ m.max[c] = signed(&mut r)?;
+ m.gamma[c] = unsigned(&mut r)?;
+ m.base_offset[c] = signed(&mut r)?;
+ m.alternate_offset[c] = signed(&mut r)?;
+ }
+ if !multichannel {
+ m.fill_from_first();
+ }
+ m.is_valid().then_some(m)
+ }
+
+ fn fill_from_first(&mut self) {
+ for a in [&mut self.min, &mut self.max, &mut self.gamma, &mut self.base_offset, &mut self.alternate_offset] {
+ a[1] = a[0];
+ a[2] = a[0];
+ }
+ }
+
+ /// The gain parameters from Adobe `hdrgm` XMP (the gain map image's packet), for files
+ /// without ISO 21496-1 metadata (the first Ultra HDR files). Attributes or `rdf:Seq` lists.
+ pub fn from_hdrgm_xmp(xmp: &str) -> Option {
+ if !xmp.contains("hdrgm:") {
+ return None;
+ }
+ let base_rendition_hdr = xmp_values(xmp, "BaseRenditionIsHDR").is_some_and(|v| v.first().is_some_and(|s| s.eq_ignore_ascii_case("true")));
+ if base_rendition_hdr {
+ return None;
+ }
+ let get = |name: &str, default: f32| -> Option<([f32; 3], bool)> {
+ let Some(v) = xmp_values(xmp, name) else { return Some(([default; 3], false)) };
+ let f: Vec = v.iter().map(|s| s.trim().parse::().ok()).collect::