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::>()?; + match f.as_slice() { + [a] => Some(([*a; 3], false)), + [a, b, c] => Some(([*a, *b, *c], true)), + _ => None, + } + }; + let (max, m1) = get("GainMapMax", f32::NAN)?; + if max.iter().any(|v| v.is_nan()) { + return None; // the one required parameter + } + let (min, m2) = get("GainMapMin", 0.0)?; + let (gamma, m3) = get("Gamma", 1.0)?; + let (base_offset, m4) = get("OffsetSDR", 1.0 / 64.0)?; + let (alternate_offset, m5) = get("OffsetHDR", 1.0 / 64.0)?; + let (cap_min, _) = get("HDRCapacityMin", 0.0)?; + let max_gain = max.iter().copied().fold(f32::MIN, f32::max); + let (cap_max, _) = get("HDRCapacityMax", max_gain)?; + let m = GainMapMeta { + multichannel: m1 || m2 || m3 || m4 || m5, + use_base_colour_space: true, + base_headroom: cap_min[0].max(0.0), + alternate_headroom: cap_max[0], + min, + max, + gamma, + base_offset, + alternate_offset, + }; + m.is_valid().then_some(m) + } + + /// The gain map image's XMP: the Adobe `hdrgm` parameters (read by Android and Chrome, which + /// ignore the ISO payload) and Apple's description of the same map (what iMessage keeps). + pub fn to_gain_map_xmp(&self) -> String { + let names = ["GainMapMin", "GainMapMax", "Gamma", "OffsetSDR", "OffsetHDR"]; + let values = [&self.min, &self.max, &self.gamma, &self.base_offset, &self.alternate_offset]; + let uniform = |v: &[f32; 3]| !self.multichannel || (v[0] == v[1] && v[1] == v[2]); + let mut s = String::from(XMP_OPEN); + s.push_str(r#"'); + // per-channel values are an rdf:Seq of three, not a comma list: readers parse each item + for (n, v) in names.iter().zip(values) { + if !uniform(v) { + s.push_str(&format!("")); + for x in v { + s.push_str(&format!("{}", num(*x))); + } + s.push_str(&format!("")); + } + } + s.push_str(""); + s.push_str(&self.apple_description()); + s.push_str(XMP_CLOSE); + s + } + + /// Apple's own description of the map (`apdi`, `HDRGainMap`, `HDRToneMap` namespaces). + fn apple_description(&self) -> String { + // 'L008' (8-bit luminance) or '444f'; the stored map must really have that many channels + let format = if self.multichannel { 0x3434_3466u32 } else { 0x4C30_3038 }; + let mut s = String::from( + r#""#, + ); + s.push_str("urn:com:apple:photo:2020:aux:hdrgainmap"); + s.push_str(&format!("{format}{format}")); + s.push_str("131072"); + // linear, not log2: the headroom multiplier itself + s.push_str(&format!("{}", num(self.alternate_headroom.exp2()))); + s.push_str(&format!("{}", num(self.alternate_headroom))); + s.push_str(""); + for c in 0..self.channels() { + s.push_str(&format!( + r#"{}{}{}{}{}"#, + num(self.min[c]), + num(self.max[c]), + num(self.gamma[c]), + num(self.base_offset[c]), + num(self.alternate_offset[c]) + )); + } + s.push_str(""); + s.push_str(&format!("{}", num(self.base_headroom))); + s.push_str("True1"); + s.push_str(""); + s + } +} + +const FLAG_MULTICHANNEL: u8 = 0x80; +const FLAG_BASE_COLOUR_SPACE: u8 = 0x40; +const FLAG_COMMON_DENOMINATOR: u8 = 0x08; +const FLAG_BACKWARD: u8 = 0x04; + +const XMP_OPEN: &str = ""; +const XMP_CLOSE: &str = ""; + +/// A short decimal (up to 6 significant digits, no exponent for ordinary values). +fn num(v: f32) -> String { + if !v.is_finite() { + return "0".into(); + } + let s = format!("{:.6}", v); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" || s.is_empty() { "0".into() } else { s.to_string() } +} + +/// The values of `hdrgm:`: an attribute (`name="v"`) or an element holding an `rdf:Seq`. +fn xmp_values(xmp: &str, name: &str) -> Option> { + let attr = format!("hdrgm:{name}="); + if let Some(i) = xmp.find(&attr) { + let rest = xmp.get(i + attr.len()..)?; + let q = rest.chars().next()?; + if q == '"' || q == '\'' { + let body = rest.get(1..)?; + let end = body.find(q)?; + return Some(vec![body.get(..end)?.to_string()]); + } + } + let open = format!(""); + let close = format!(""); + let i = xmp.find(&open)?; + let body = xmp.get(i + open.len()..)?; + let body = body.get(..body.find(&close)?)?; + let mut out = Vec::new(); + let mut rest = body; + while let Some(a) = rest.find("") { + let after = rest.get(a + 8..)?; + let b = after.find("")?; + out.push(after.get(..b)?.to_string()); + rest = after.get(b..)?; + if out.len() > 3 { + return None; + } + } + if out.is_empty() { + // a plain element value + out.push(body.trim().to_string()); + } + Some(out) +} + +struct Reader<'a> { + b: &'a [u8], + at: usize, +} + +impl Reader<'_> { + fn take(&mut self) -> Option<[u8; N]> { + let s = self.b.get(self.at..self.at.checked_add(N)?)?; + self.at += N; + s.try_into().ok() + } + fn u8(&mut self) -> Option { + self.take::<1>().map(|a| a[0]) + } + fn u16(&mut self) -> Option { + self.take().map(u16::from_be_bytes) + } + fn u32(&mut self) -> Option { + self.take().map(u32::from_be_bytes) + } +} + +/// A gain map image: `channels` (1 or 3) interleaved 8-bit codes. +#[derive(Clone, Debug, PartialEq)] +pub struct GainImage { + pub width: usize, + pub height: usize, + pub channels: usize, + pub data: Vec, +} + +impl GainImage { + /// Bilinear code of channel `c` at gain map coordinates (`x`, `y`) (pixel centres at +0.5). + fn sample(&self, x: f32, y: f32, c: usize) -> f32 { + let (w, h) = (self.width, self.height); + let fx = (x - 0.5).clamp(0.0, (w - 1) as f32); + let fy = (y - 0.5).clamp(0.0, (h - 1) as f32); + let (x0, y0) = (fx as usize, fy as usize); + let (x1, y1) = ((x0 + 1).min(w - 1), (y0 + 1).min(h - 1)); + let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); + let at = |x: usize, y: usize| self.data.get((y * w + x) * self.channels + c).copied().unwrap_or(0) as f32; + let a = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * tx; + let b = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * tx; + a + (b - a) * ty + } +} + +/// How [`compute`] builds the map. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GainMapOptions { + /// Downscale factor of the map (1, 2 or 4). + pub scale: usize, + /// Three gains (R, G, B) instead of one luminance gain. Apple's readers want a single + /// channel, so this is off by default. + pub multichannel: bool, + pub gamma: f32, + /// Offsets added to SDR and HDR before the ratio (keep blacks finite and noise quiet). + pub offset: f32, +} + +impl Default for GainMapOptions { + fn default() -> Self { + GainMapOptions { scale: 2, multichannel: false, gamma: 1.0, offset: 1.0 / 64.0 } + } +} + +/// Measure the gain map that turns `sdr` into `hdr` (both `width ร— height` linear RGB in the same +/// primaries; `luma` = that space's luminance weights). +pub fn compute( + sdr: &[[f32; 3]], + hdr: &[[f32; 3]], + width: usize, + height: usize, + luma: [f32; 3], + o: &GainMapOptions, +) -> Result<(GainImage, GainMapMeta)> { + let n = width.checked_mul(height).ok_or_else(|| Error::Encode("gain map: image too large".into()))?; + if n == 0 || sdr.len() < n || hdr.len() < n { + return Err(Error::Encode("gain map: SDR and HDR renditions must match the image size".into())); + } + if !matches!(o.scale, 1 | 2 | 4) || !(o.gamma > 0.0 && o.gamma.is_finite()) || !(o.offset > 0.0 && o.offset.is_finite()) { + return Err(Error::Encode("gain map: scale must be 1, 2 or 4, gamma and offset positive".into())); + } + let s = o.scale; + let (gw, gh) = (width.div_ceil(s), height.div_ceil(s)); + let ch = if o.multichannel { 3 } else { 1 }; + let y = |p: &[f32; 3]| luma[0] * p[0] + luma[1] * p[1] + luma[2] * p[2]; + let ratio = |a: f32, b: f32| ((b.max(0.0) + o.offset) / (a.max(0.0) + o.offset)).log2(); + // block means of the log gains + let mut gains = vec![0f32; gw * gh * ch]; + let mut lo = [f32::MAX; 3]; + let mut hi = [f32::MIN; 3]; + for gy in 0..gh { + for gx in 0..gw { + let mut acc = [0f32; 3]; + let mut count = 0f32; + for yy in gy * s..((gy + 1) * s).min(height) { + for xx in gx * s..((gx + 1) * s).min(width) { + let i = yy * width + xx; + let (Some(a), Some(b)) = (sdr.get(i), hdr.get(i)) else { continue }; + if o.multichannel { + for c in 0..3 { + acc[c] += ratio(a[c], b[c]); + } + } else { + acc[0] += ratio(y(a), y(b)); + } + count += 1.0; + } + } + for c in 0..ch { + let g = if count > 0.0 { acc[c] / count } else { 0.0 }; + let g = if g.is_finite() { g } else { 0.0 }; + gains[(gy * gw + gx) * ch + c] = g; + lo[c] = lo[c].min(g); + hi[c] = hi[c].max(g); + } + } + } + let mut m = GainMapMeta { + multichannel: o.multichannel, + gamma: [o.gamma; 3], + base_offset: [o.offset; 3], + alternate_offset: [o.offset; 3], + ..GainMapMeta::default() + }; + for c in 0..ch { + // the range always includes 0 (no change) so an SDR-only area encodes exactly + m.min[c] = lo[c].min(0.0); + m.max[c] = hi[c].max(m.min[c] + 1e-4); + } + if !o.multichannel { + m.fill_from_first(); + } + m.base_headroom = 0.0; + m.alternate_headroom = m.max.iter().copied().fold(0.0f32, f32::max).max(1e-3); + let data = gains + .iter() + .enumerate() + .map(|(i, g)| { + let c = i % ch; + let norm = ((g - m.min[c]) / (m.max[c] - m.min[c])).clamp(0.0, 1.0); + (norm.powf(o.gamma) * 255.0).round() as u8 + }) + .collect(); + Ok((GainImage { width: gw, height: gh, channels: ch, data }, m)) +} + +/// Rebuild the HDR rendition in place: `base` (`width ร— height` linear RGB) is replaced by the +/// rendition at `weight` (0 = the base, 1 = the full alternate rendition; a display with headroom +/// `H` uses `(H โˆ’ base_headroom) / (alternate_headroom โˆ’ base_headroom)`, clamped). +pub fn apply(base: &mut [[f32; 3]], width: usize, height: usize, gain: &GainImage, m: &GainMapMeta, weight: f32) -> Result<()> { + let n = width.checked_mul(height).ok_or_else(|| Error::Encode("gain map: image too large".into()))?; + if base.len() < n + || gain.width == 0 + || gain.height == 0 + || !matches!(gain.channels, 1 | 3) + || gain.data.len() < gain.width * gain.height * gain.channels + { + return Err(Error::Encode("gain map: base and gain map sizes don't match".into())); + } + if !m.is_valid() { + return Err(Error::Encode("gain map: unusable metadata".into())); + } + let weight = if weight.is_finite() { weight.clamp(0.0, 1.0) } else { 1.0 }; + let (sx, sy) = (gain.width as f32 / width as f32, gain.height as f32 / height as f32); + let inv_gamma = m.gamma.map(|g| 1.0 / g); + for (i, p) in base.iter_mut().enumerate().take(n) { + let (x, y) = ((i % width) as f32 + 0.5, (i / width) as f32 + 0.5); + let (gx, gy) = (x * sx, y * sy); + let mut g = [0f32; 3]; + for (c, gc) in g.iter_mut().enumerate() { + let src = if gain.channels == 3 { c } else { 0 }; + let norm = (gain.sample(gx, gy, src) / 255.0).clamp(0.0, 1.0).powf(inv_gamma[c]); + *gc = m.min[c] + (m.max[c] - m.min[c]) * norm; + } + for c in 0..3 { + let v = (p[c] + m.base_offset[c]) * (g[c] * weight).exp2() - m.alternate_offset[c]; + p[c] = v.max(0.0); + } + } + Ok(()) +} + +/// The primary image's container XMP: `hdrgm:Version` (marks the file as a gain map image for +/// Adobe/Google readers) and the Container directory naming the gain map and its length. +fn primary_description(gain_len: usize) -> String { + format!( + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""# + ), + gain_len + ) +} + +/// `xmp` with `description` added as another `rdf:Description` (a fresh packet when there is +/// none, or when it has no `rdf:RDF` to add to). +fn merge_xmp(xmp: Option<&str>, description: &str) -> String { + if let Some(x) = xmp + && let Some(i) = x.rfind("") + && let (Some(a), Some(b)) = (x.get(..i), x.get(i..)) + { + return format!("{a}{description}{b}"); + } + format!("{XMP_OPEN}{description}{XMP_CLOSE}") +} + +/// Offsets within the MPF payload (from "MPF\0"): the endian field, then the index IFD (3 tags). +const MPF_ENTRIES_FROM_ENDIAN: usize = 8 + 2 + 3 * 12 + 4; + +/// The primary image's MPF payload, entry sizes and offset to be patched once the file exists. +fn mpf_placeholder() -> Vec { + let mut p = b"MPF\0MM".to_vec(); + p.extend_from_slice(&42u16.to_be_bytes()); + p.extend_from_slice(&8u32.to_be_bytes()); // first IFD + p.extend_from_slice(&3u16.to_be_bytes()); + let tag = |p: &mut Vec, tag: u16, ty: u16, count: u32, value: [u8; 4]| { + p.extend_from_slice(&tag.to_be_bytes()); + p.extend_from_slice(&ty.to_be_bytes()); + p.extend_from_slice(&count.to_be_bytes()); + p.extend_from_slice(&value); + }; + tag(&mut p, 0xB000, 7, 4, *b"0100"); // MPFVersion + tag(&mut p, 0xB001, 4, 1, 2u32.to_be_bytes()); // NumberOfImages + tag(&mut p, 0xB002, 7, 32, (MPF_ENTRIES_FROM_ENDIAN as u32).to_be_bytes()); // MPEntry + p.extend_from_slice(&0u32.to_be_bytes()); // no next IFD + // entry 0: baseline primary (0x030000) flagged as the representative image (bit 29) + for attr in [0x2003_0000u32, jpeg::MPF_GAIN_MAP] { + p.extend_from_slice(&attr.to_be_bytes()); + p.extend_from_slice(&[0; 12]); // size, offset, two dependent-image entries + } + p +} + +/// Encode a gain map JPEG: `base` (8-bit SDR, 3 or 4 channels, colour-managed by `meta.icc`) and +/// its gain map, both at `quality`. `meta.xmp` is kept, with the gain map container added. +pub fn encode_jpeg(base: &EncodeImage, gain: &GainImage, m: &GainMapMeta, quality: u8, sub: ChromaSubsampling, meta: &EncodeMeta) -> Result> { + let Samples::U8(data) = base.samples else { + return Err(Error::Encode("gain map JPEG needs an 8-bit base image".into())); + }; + let (w, h) = (base.width as usize, base.height as usize); + if !matches!(base.channels, 3 | 4) || data.len() < w * h * base.channels as usize { + return Err(Error::Encode("gain map JPEG needs an RGB base image".into())); + } + if w == 0 || h == 0 || w > u16::MAX as usize || h > u16::MAX as usize { + return Err(Error::Encode("JPEG dimensions are limited to 65535".into())); + } + if !matches!(gain.channels, 1 | 3) || gain.width == 0 || gain.height == 0 || gain.data.len() < gain.width * gain.height * gain.channels { + return Err(Error::Encode("gain map image is malformed".into())); + } + if !m.is_valid() || m.multichannel != (gain.channels == 3) { + return Err(Error::Encode("gain map metadata doesn't match the map".into())); + } + // jpeg_par writes 4:4:4 or 4:2:0 + let sub = if sub == ChromaSubsampling::S422 { ChromaSubsampling::S420 } else { sub }; + + let gain_segs = vec![(0xE1, xmp_segment(&m.to_gain_map_xmp())?), (0xE2, m.to_iso())]; + let gain_jpeg = jpeg_par::encode(&gain.data, gain.width, gain.height, gain.channels, quality, ChromaSubsampling::S444, &gain_segs); + + let xmp = merge_xmp(meta.xmp, &primary_description(gain_jpeg.len())); + let mut segs = jpeg_app_segments(&EncodeMeta { xmp: Some(&xmp), ..*meta })?; + let mut iso_marker = ISO_URN.to_vec(); + iso_marker.extend_from_slice(&[0, 0, 0, 0]); // versions only: "this image has a gain map" + segs.push((0xE2, iso_marker)); + segs.push((0xE2, mpf_placeholder())); + let mut primary = jpeg_par::encode(data, w, h, base.channels as usize, quality, sub, &segs); + + // patch the MPF index: sizes, and the gain map's offset from the MPF endian field + let markers = jpeg::parse_markers(&primary).ok_or_else(|| Error::Encode("gain map JPEG: primary image unreadable".into()))?; + let endian = markers.mpf.as_ref().map(|m| m.0).ok_or_else(|| Error::Encode("gain map JPEG: MPF segment missing".into()))?; + let entries = endian + MPF_ENTRIES_FROM_ENDIAN; + let (plen, glen) = (primary.len(), gain_jpeg.len()); + let fields = [(entries + 4, plen), (entries + 8, 0), (entries + 20, glen), (entries + 24, plen - endian)]; + for (at, v) in fields { + let v = u32::try_from(v).map_err(|_| Error::Encode("gain map JPEG larger than 4 GiB".into()))?; + let slot = primary.get_mut(at..at + 4).ok_or_else(|| Error::Encode("gain map JPEG: truncated MPF segment".into()))?; + slot.copy_from_slice(&v.to_be_bytes()); + } + primary.extend_from_slice(&gain_jpeg); + Ok(primary) +} + +/// The gain map of a gain map JPEG. +#[derive(Clone, Debug, PartialEq)] +pub struct JpegGainMap { + pub gain: GainImage, + pub meta: GainMapMeta, +} + +/// Find and decode the gain map of a JPEG (`None` when it has none or it's unusable). The +/// primary image is decoded as usual ([`crate::decode`]); combine the two with [`apply`]. +pub fn read_jpeg(bytes: &[u8]) -> Option { + let m = jpeg::parse_markers(bytes)?; + let marked = m.iso_gainmap.is_some() || m.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:Version")); + if !marked { + return None; + } + for e in jpeg::mpf_images(&m) { + let Some(data) = bytes.get(e.offset..e.offset.saturating_add(e.len)) else { continue }; + let Some(gm) = jpeg::parse_markers(data) else { continue }; + let meta = gm.iso_gainmap.as_deref().and_then(GainMapMeta::from_iso).or_else(|| gm.xmp.as_deref().and_then(GainMapMeta::from_hdrgm_xmp)); + let Some(meta) = meta else { continue }; + let Ok((w, h, ch, codes)) = jpeg::decode_codes8(data) else { continue }; + if ch == 3 && !meta.multichannel { + // a single-channel map stored as RGB: use its first channel + let g: Vec = codes.as_chunks::<3>().0.iter().map(|c| c[0]).collect(); + return Some(JpegGainMap { gain: GainImage { width: w, height: h, channels: 1, data: g }, meta }); + } + let mut meta = meta; + if ch == 1 && meta.multichannel { + meta.multichannel = false; + meta.fill_from_first(); + } + return Some(JpegGainMap { gain: GainImage { width: w, height: h, channels: ch, data: codes }, meta }); + } + None +} + +/// Whether `bytes` is a gain map JPEG with a usable gain map (cheap: no pixel decode). +pub fn is_gain_map_jpeg(bytes: &[u8]) -> bool { + crate::sniff(bytes) == Some(Format::Jpeg) + && jpeg::parse_markers(bytes).is_some_and(|m| { + (m.iso_gainmap.is_some() || m.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:Version"))) + && jpeg::mpf_images(&m) + .iter() + .any(|e| bytes.get(e.offset..e.offset.saturating_add(e.len)).and_then(jpeg::parse_markers).is_some_and(|g| g.is_gain_map())) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn meta3() -> GainMapMeta { + GainMapMeta { + multichannel: true, + use_base_colour_space: true, + base_headroom: 0.0, + alternate_headroom: 2.5, + min: [-0.25, -0.5, 0.0], + max: [2.5, 2.25, 2.0], + gamma: [1.0, 1.25, 0.75], + base_offset: [0.015625; 3], + alternate_offset: [0.015625, 0.03125, 0.015625], + } + } + + #[test] + fn iso_metadata_round_trips() { + for m in [GainMapMeta { alternate_headroom: 3.0, max: [3.0; 3], ..GainMapMeta::default() }, meta3()] { + let p = m.to_iso(); + assert!(p.starts_with(ISO_URN)); + let back = GainMapMeta::from_iso(&p[ISO_URN.len()..]).unwrap(); + for (a, b) in [(back.min, m.min), (back.max, m.max), (back.gamma, m.gamma), (back.alternate_offset, m.alternate_offset)] { + for c in 0..3 { + assert!((a[c] - b[c]).abs() < 1e-6, "{a:?} {b:?}"); + } + } + assert_eq!(back.multichannel, m.multichannel); + assert!((back.alternate_headroom - m.alternate_headroom).abs() < 1e-6); + } + } + + #[test] + fn iso_common_denominator_and_rejections() { + // common-denominator form, single channel: d = 4, headrooms 0 and 2, min 0, max 2, gamma 1, offsets 0 + let mut p = vec![0, 0, 0, 0, FLAG_COMMON_DENOMINATOR]; + for v in [4u32, 0, 8, 0, 8, 4, 0, 0] { + p.extend_from_slice(&v.to_be_bytes()); + } + let m = GainMapMeta::from_iso(&p).unwrap(); + assert_eq!((m.alternate_headroom, m.max[2], m.gamma[1]), (2.0, 2.0, 1.0)); + // versions only (a primary image's marker), unknown version, truncated, backward + assert!(GainMapMeta::from_iso(&[0, 0, 0, 0]).is_none()); + assert!(GainMapMeta::from_iso(&[0, 1, 0, 0, 0]).is_none()); + assert!(GainMapMeta::from_iso(&p[..p.len() - 1]).is_none()); + let mut back = p.clone(); + back[4] |= FLAG_BACKWARD; + assert!(GainMapMeta::from_iso(&back).is_none()); + // a zero denominator is rejected, not divided by + let mut z = p.clone(); + z[5..9].copy_from_slice(&0u32.to_be_bytes()); + assert!(GainMapMeta::from_iso(&z).is_none()); + } + + #[test] + fn hdrgm_xmp_round_trips_including_per_channel_sequences() { + for m in [GainMapMeta { alternate_headroom: 2.0, max: [2.0; 3], ..GainMapMeta::default() }, meta3()] { + let x = m.to_gain_map_xmp(); + assert!(x.contains("apdi:AuxiliaryImageType")); + if m.multichannel { + assert!(x.contains("2.5"), "{x}"); + } + let back = GainMapMeta::from_hdrgm_xmp(&x).unwrap(); + assert_eq!(back.multichannel, m.multichannel); + for c in 0..3 { + assert!((back.max[c] - m.max[c]).abs() < 1e-5 && (back.gamma[c] - m.gamma[c]).abs() < 1e-5); + } + assert!((back.alternate_headroom - m.alternate_headroom).abs() < 1e-5); + } + assert!(GainMapMeta::from_hdrgm_xmp("").is_none(), "no GainMapMax"); + assert!(GainMapMeta::from_hdrgm_xmp("hdrgm:GainMapMax=\"nope\"").is_none()); + } + + fn renditions(w: usize, h: usize) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) { + // a ramp: SDR rolls off at 1, HDR keeps going to 6 + let hdr: Vec<[f32; 3]> = (0..w * h) + .map(|i| { + let t = (i % w) as f32 / (w - 1) as f32; + let v = 0.01 + 6.0 * t * t; + [v, v * 0.8, v * 0.6] + }) + .collect(); + // a luminance tone map (one scale per pixel, as LightCraft's renders do), so a single + // luminance gain can rebuild it exactly + let sdr = hdr + .iter() + .map(|p| { + let y = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]; + let k = 1.0 / (1.0 + y * 0.5); + p.map(|v| v * k) + }) + .collect(); + (sdr, hdr) + } + + #[test] + fn compute_then_apply_recovers_the_hdr_rendition() { + let (w, h) = (64, 8); + let (sdr, hdr) = renditions(w, h); + for multichannel in [false, true] { + let o = GainMapOptions { scale: 1, multichannel, ..GainMapOptions::default() }; + let (g, m) = compute(&sdr, &hdr, w, h, [0.2126, 0.7152, 0.0722], &o).unwrap(); + assert!(m.is_valid() && m.alternate_headroom > 1.0); + let mut out = sdr.clone(); + apply(&mut out, w, h, &g, &m, 1.0).unwrap(); + for (a, b) in out.iter().zip(&hdr) { + for c in 0..3 { + // 8-bit codes over the gain range: within ~2 % in linear light + assert!((a[c] - b[c]).abs() <= 0.03 * b[c] + 0.01, "{multichannel}: {a:?} vs {b:?}"); + } + } + // weight 0 is the base + let mut base = sdr.clone(); + apply(&mut base, w, h, &g, &m, 0.0).unwrap(); + for (a, b) in base.iter().zip(&sdr) { + assert!((a[0] - b[0]).abs() < 1e-5); + } + } + } + + #[test] + fn jpeg_round_trip_and_container_structure() { + let (w, h) = (96, 64); + let (sdr, hdr) = renditions(w, h); + let (g, m) = compute(&sdr, &hdr, w, h, [0.2126, 0.7152, 0.0722], &GainMapOptions::default()).unwrap(); + let base: Vec = + sdr.iter().flat_map(|p| p.map(|v| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8)).collect(); + let user_xmp = r#""#; + let meta = EncodeMeta { xmp: Some(user_xmp), ..EncodeMeta::default() }; + let file = encode_jpeg(&EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&base)), &g, &m, 90, ChromaSubsampling::S444, &meta).unwrap(); + assert!(is_gain_map_jpeg(&file)); + // the primary decodes as an ordinary JPEG of the right size, and keeps the user's XMP + let d = crate::decode(&file, crate::DecodeOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (w as u32, h as u32)); + let x = d.xmp.unwrap(); + assert!(x.contains("xmlns:dc=") && x.contains("hdrgm:Version") && x.contains("Item:Semantic=\"GainMap\"")); + // the MPF index points at the gain map, typed as one + let pm = jpeg::parse_markers(&file).unwrap(); + let e = jpeg::mpf_images(&pm); + assert_eq!(e.len(), 1); + assert_eq!(e[0].kind, jpeg::MPF_GAIN_MAP); + assert_eq!(e[0].offset + e[0].len, file.len()); + // and the gain map reads back + let r = read_jpeg(&file).unwrap(); + assert_eq!((r.gain.width, r.gain.height, r.gain.channels), (48, 32, 1)); + assert!((r.meta.max[0] - m.max[0]).abs() < 1e-5); + // the gain map is never a thumbnail + if let Some(t) = jpeg::embedded_thumbnail(&file, 32, 16) { + assert_ne!(t.source, crate::ThumbnailSource::MpfPreview); + } + } + + #[test] + fn garbage_never_panics() { + assert!(read_jpeg(b"").is_none()); + assert!(read_jpeg(&[0xFF, 0xD8, 0xFF, 0xE2, 0x00, 0x04, 0, 0]).is_none()); + assert!(!is_gain_map_jpeg(&[0xFF, 0xD8])); + let mut b = vec![0xFF, 0xD8, 0xFF, 0xE2, 0, 40]; + b.extend_from_slice(ISO_URN); + b.extend_from_slice(&[0, 0, 0, 0, 0xFF, 1, 2, 3, 4]); + assert!(read_jpeg(&b).is_none()); + let g = GainImage { width: 2, height: 2, channels: 1, data: vec![0; 3] }; + assert!(apply(&mut [[0.0; 3]; 4], 2, 2, &g, &GainMapMeta::default(), 1.0).is_err()); + assert!(compute(&[], &[], 0, 0, [0.3, 0.6, 0.1], &GainMapOptions::default()).is_err()); + } +} diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs index 92409af3..e40cf7e7 100644 --- a/crates/codecs/src/jpeg.rs +++ b/crates/codecs/src/jpeg.rs @@ -25,6 +25,17 @@ pub(crate) struct Markers { pub adobe_transform: Option, /// Offset of the MPF TIFF header within the file and its bytes. pub mpf: Option<(usize, Vec)>, + /// The ISO 21496-1 APP2 payload after its URN: 4 bytes (versions only) on a primary image + /// that has a gain map, the full metadata on the gain map image itself. + pub iso_gainmap: Option>, +} + +impl Markers { + /// This image is a gain map (ISO 21496-1 metadata or Adobe `hdrgm` gain parameters), not a + /// picture: never a preview, never the image to show. + pub fn is_gain_map(&self) -> bool { + self.iso_gainmap.as_ref().is_some_and(|p| p.len() > 4) || self.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:GainMapMax")) + } } pub(crate) fn parse_markers(b: &[u8]) -> Option { @@ -88,6 +99,10 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option { icc_chunks.push((seg[12], &seg[14..])); } else if seg.starts_with(b"MPF\0") && m.mpf.is_none() { m.mpf = Some((start + 4, seg[4..].to_vec())); + } else if let Some(p) = seg.strip_prefix(crate::gainmap::ISO_URN) + && m.iso_gainmap.is_none() + { + m.iso_gainmap = Some(p.to_vec()); } } 0xEE if seg.starts_with(b"Adobe") && seg.len() >= 12 => { @@ -260,8 +275,11 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) -> if let (Some(ex), Some((o, l))) = (m.exif.as_deref(), summary.thumbnail) { candidates.push((&ex[o..o + l], ThumbnailSource::ExifThumbnail)); } - for (o, l) in mpf_images(&m) { - if let Some(s) = bytes.get(o..o.saturating_add(l)) { + for e in mpf_images(&m) { + if e.kind == MPF_GAIN_MAP { + continue; + } + if let Some(s) = bytes.get(e.offset..e.offset.saturating_add(e.len)) { candidates.push((s, ThumbnailSource::MpfPreview)); } } @@ -269,6 +287,10 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) -> let mut best: Option<(u32, &[u8], ThumbnailSource)> = None; for (data, src) in candidates { let Some(pm) = parse_markers(data) else { continue }; + // a gain map (also when its MPF entry doesn't say so) is greyscale gain data, not a preview + if pm.is_gain_map() { + continue; + } let long = pm.width.max(pm.height); // Previews must have the main image's aspect ratio (within 2%), else they are letterboxed/cropped. let aspect_ok = m.width > 0 @@ -290,8 +312,21 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) -> Some(Thumbnail { image: d.to_srgb8(), orientation, source, source_width: m.width, source_height: m.height }) } -/// (file offset, length) of MPF-listed images other than the primary. -fn mpf_images(m: &Markers) -> Vec<(usize, usize)> { +/// MP type code of a gain map image (CIPA DC-007 entry attribute, low 24 bits). +pub(crate) const MPF_GAIN_MAP: u32 = 0x05_0000; + +/// An MPF-listed image other than the primary. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct MpfEntry { + /// File offset and length of the image. + pub offset: usize, + pub len: usize, + /// MP type code (attribute low 24 bits). + pub kind: u32, +} + +/// The MPF-listed images other than the primary. +pub(crate) fn mpf_images(m: &Markers) -> Vec { let Some((base, data)) = &m.mpf else { return vec![] }; let Some(t) = exif::Tiff::new(data) else { return vec![] }; let Some(ifd) = t.first_ifd() else { return vec![] }; @@ -307,14 +342,34 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> { let a = [rec[o], rec[o + 1], rec[o + 2], rec[o + 3]]; if data.starts_with(b"II") { u32::from_le_bytes(a) } else { u32::from_be_bytes(a) } }; - let (size, off) = (rd(4) as usize, rd(8) as usize); + let (attr, size, off) = (rd(0), rd(4) as usize, rd(8) as usize); if size > 0 && off > 0 { - out.push((base + off, size)); + out.push(MpfEntry { offset: base.saturating_add(off), len: size, kind: attr & 0x00FF_FFFF }); } } out } +/// Decode to the file's 8-bit samples as stored (no colour management): 1 channel for greyscale +/// files, else 3 (RGB). For data images such as gain maps, whose values are codes, not colours. +pub(crate) fn decode_codes8(bytes: &[u8]) -> Result<(usize, usize, usize, Vec)> { + let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?; + if m.components == 0 { + return Err(Error::Malformed(F, "no frame header".into())); + } + check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?; + if !matches!(m.components, 1 | 3) || m.precision != 8 { + return Err(Error::Malformed(F, "expected an 8-bit greyscale or RGB image".into())); + } + let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components; + let raw = if non_interleaved { decode_jpeg_decoder(bytes, &m, None)? } else { decode_zune(bytes, &m)? }; + let ch = raw.model.channels(); + match raw.buf { + Buf::U8(v) if v.len() >= raw.width * raw.height * ch => Ok((raw.width, raw.height, ch, v)), + _ => Err(Error::Malformed(F, "unexpected sample format".into())), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 585505f9..65798718 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -20,6 +20,7 @@ mod convert; pub mod display; pub mod encode; pub mod exif; +pub mod gainmap; mod heif; pub mod icc; mod jpeg; @@ -35,7 +36,8 @@ mod tiff_codec; mod webp; pub use encode::{ - ChromaSubsampling, EncodeImage, EncodeMeta, Samples, TiffCompression, encode_avif, encode_jpeg, encode_png, encode_tiff, encode_webp_lossless, + ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg, + encode_png, encode_tiff, encode_webp_lossless, }; pub use sniff::{Format, sniff}; pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc}; diff --git a/crates/codecs/tests/fuzz.rs b/crates/codecs/tests/fuzz.rs index 15b236b0..bfee84df 100644 --- a/crates/codecs/tests/fuzz.rs +++ b/crates/codecs/tests/fuzz.rs @@ -36,6 +36,17 @@ fn try_all(bytes: &[u8]) { let _ = read_header(bytes); let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) }); let _ = lightcraft_codecs::icc::parse(bytes); + let _ = lightcraft_codecs::gainmap::read_jpeg(bytes); + let _ = lightcraft_codecs::gainmap::is_gain_map_jpeg(bytes); +} + +fn gain_map_jpeg(meta: &EncodeMeta) -> Vec { + use lightcraft_codecs::gainmap::{GainMapOptions, compute, encode_jpeg}; + let hdr: Vec<[f32; 3]> = (0..24 * 16).map(|i| [i as f32 * 0.02; 3]).collect(); + let sdr: Vec<[f32; 3]> = hdr.iter().map(|p| p.map(|v| v / (1.0 + v))).collect(); + let (g, m) = compute(&sdr, &hdr, 24, 16, [0.2126, 0.7152, 0.0722], &GainMapOptions::default()).unwrap(); + let base: Vec = sdr.iter().flat_map(|p| p.map(|v| (v * 255.0) as u8)).collect(); + encode_jpeg(&EncodeImage::new(24, 16, 3, Samples::U8(&base)), &g, &m, 80, ChromaSubsampling::S420, meta).unwrap() } #[test] @@ -91,6 +102,7 @@ fn seeds() -> &'static Vec> { encode_tiff(&e, TiffCompression::Lzw, &meta).unwrap(), encode_tiff(&EncodeImage::new(24, 16, 3, Samples::F32(&f32s)), TiffCompression::Deflate, &meta).unwrap(), encode_webp_lossless(&e, &meta).unwrap(), + gain_map_jpeg(&meta), ] }) } @@ -106,7 +118,7 @@ proptest! { } #[test] - fn mutated_valid_files(which in 0usize..6, flips in proptest::collection::vec((any::(), any::()), 1..12), cut in any::()) { + fn mutated_valid_files(which in 0usize..7, flips in proptest::collection::vec((any::(), any::()), 1..12), cut in any::()) { let mut b = seeds()[which].clone(); for (i, v) in flips { let i = i.index(b.len()); diff --git a/crates/develop/src/controls.rs b/crates/develop/src/controls.rs index c8a001b7..83b26403 100644 --- a/crates/develop/src/controls.rs +++ b/crates/develop/src/controls.rs @@ -140,6 +140,13 @@ controls! { "light.shadows" => light.shadows, "Shadows", Light, -100, 100, 0, 1, 0, Centered; "light.whites" => light.whites, "Whites", Light, -100, 100, 0, 1, 0, Centered; "light.blacks" => light.blacks, "Blacks", Light, -100, 100, 0, 1, 0, Centered; + "hdr.maxEv" => hdr.max_ev, "HDR Headroom", Light, 0, 5, 3, 0.1, 1, Plain; + "hdr.sdrBrightness" => hdr.sdr_brightness, "SDR Brightness", Light, -100, 100, 0, 1, 0, Centered; + "hdr.sdrContrast" => hdr.sdr_contrast, "SDR Contrast", Light, -100, 100, 0, 1, 0, Centered; + "hdr.sdrHighlights" => hdr.sdr_highlights, "SDR Highlights", Light, -100, 100, 0, 1, 0, Centered; + "hdr.sdrShadows" => hdr.sdr_shadows, "SDR Shadows", Light, -100, 100, 0, 1, 0, Centered; + "hdr.sdrWhites" => hdr.sdr_whites, "SDR Whites", Light, -100, 100, 0, 1, 0, Centered; + "hdr.sdrClarity" => hdr.sdr_clarity, "SDR Clarity", Light, -100, 100, 0, 1, 0, Centered; "curve.highlights" => curve.highlights, "Highlights", Curve, -100, 100, 0, 1, 0, Centered; "curve.lights" => curve.lights, "Lights", Curve, -100, 100, 0, 1, 0, Centered; "curve.darks" => curve.darks, "Darks", Curve, -100, 100, 0, 1, 0, Centered; diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index c9e158c6..c2edd76c 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -167,6 +167,27 @@ mod tests { assert_ne!(back.hash64(), DevelopSettings::default().hash64()); } + #[test] + fn default_hdr_is_not_serialized_so_existing_hashes_stay() { + let s = DevelopSettings::default(); + assert!(s.to_json().get("hdr").is_none(), "HDR off must not change the settings JSON or hash64"); + let mut on = s.clone(); + on.hdr.enabled = true; + assert!(on.to_json().get("hdr").is_some()); + assert_ne!(on.hash64(), s.hash64()); + assert_eq!(DevelopSettings::from_json(&on.to_json()).unwrap(), on); + } + + #[test] + fn hdr_peak_is_finite_for_a_non_finite_headroom() { + let mut h = Hdr { enabled: true, max_ev: f64::NAN, ..Hdr::default() }; + assert_eq!(h.peak(), (Hdr::DEFAULT_MAX_EV as f32).exp2()); + h.max_ev = f64::INFINITY; + assert_eq!(h.peak(), (Hdr::DEFAULT_MAX_EV as f32).exp2()); + h.max_ev = -3.0; + assert_eq!(h.peak(), 1.0); + } + #[test] fn missing_fields_take_defaults_and_unknown_ignored() { let s = DevelopSettings::from_json(&json!({"light": {"exposure": 0.5}, "futureThing": 3})).unwrap(); diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index 3a365579..8bd15060 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -102,7 +102,7 @@ impl SettingsGroup { SettingsGroup::Profile => &["profile"], SettingsGroup::Treatment => &["treatment"], SettingsGroup::WhiteBalance => &["wb"], - SettingsGroup::Light => &["light"], + SettingsGroup::Light => &["light", "hdr"], SettingsGroup::ToneCurve => &["curve"], SettingsGroup::Color => &["color"], SettingsGroup::ColorMixer => &["mixer", "bw_mix", "point_colors"], diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 67f04056..54577bff 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -109,6 +109,10 @@ pub struct DevelopSettings { pub treatment: Treatment, pub wb: WhiteBalance, pub light: Light, + /// HDR editing: highlights above SDR white, and the SDR rendition derived from them. Not written while it is + /// the default, so settings without HDR keep their JSON and `hash64` (and every cached preview stays valid). + #[serde(skip_serializing_if = "Hdr::is_default")] + pub hdr: Hdr, pub curve: ToneCurve, pub color: ColorAdj, pub mixer: Mixer, @@ -144,6 +148,7 @@ impl Default for DevelopSettings { treatment: Treatment::Color, wb: WhiteBalance::default(), light: Light::default(), + hdr: Hdr::default(), curve: ToneCurve::default(), color: ColorAdj::default(), mixer: Mixer::default(), @@ -1027,3 +1032,78 @@ impl Enhance { self.denoise_on.unwrap_or(self.denoise > 0.0) } } + +/// HDR editing. When enabled, an HDR render ([`Hdr::peak`]) keeps highlights above SDR white (1.0) +/// up to `2^max_ev`; every SDR render (previews on SDR displays, 8/16-bit exports and a gain map's +/// base image) uses the SDR rendition ([`DevelopSettings::sdr_rendition`]): the same edit with the +/// `sdr_*` offsets applied. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Hdr { + pub enabled: bool, + /// Headroom limit: stops above SDR white the HDR render may reach. + pub max_ev: f64, + /// SDR rendition offsets (โˆ’100..100): brightness (ยฑ1 EV), then added to the matching sliders. + pub sdr_brightness: f64, + pub sdr_contrast: f64, + pub sdr_highlights: f64, + pub sdr_shadows: f64, + pub sdr_whites: f64, + pub sdr_clarity: f64, +} + +impl Default for Hdr { + fn default() -> Self { + Self { + enabled: false, + max_ev: Hdr::DEFAULT_MAX_EV, + sdr_brightness: 0.0, + sdr_contrast: 0.0, + sdr_highlights: 0.0, + sdr_shadows: 0.0, + sdr_whites: 0.0, + sdr_clarity: 0.0, + } + } +} + +impl Hdr { + pub const DEFAULT_MAX_EV: f64 = 3.0; + pub const MAX_EV_LIMIT: f64 = 5.0; + + /// Whether these are the default HDR settings (HDR off, nothing changed). + pub fn is_default(&self) -> bool { + *self == Hdr::default() + } + + /// Peak linear value of an HDR render relative to SDR white (1.0 when HDR is off). A non-finite headroom + /// (a damaged file or argument) falls back to the default, so the peak is always finite and at least 1. + pub fn peak(&self) -> f32 { + if !self.enabled { + return 1.0; + } + let ev = if self.max_ev.is_finite() { self.max_ev } else { Hdr::DEFAULT_MAX_EV }; + (ev.clamp(0.0, Hdr::MAX_EV_LIMIT) as f32).exp2() + } +} + +impl DevelopSettings { + /// The settings an SDR render uses: unchanged when HDR is off; otherwise HDR off and the + /// SDR rendition offsets applied to exposure, contrast, highlights, shadows, whites, clarity. + pub fn sdr_rendition(&self) -> std::borrow::Cow<'_, DevelopSettings> { + if !self.hdr.enabled { + return std::borrow::Cow::Borrowed(self); + } + let h = self.hdr; + let mut s = self.clone(); + s.hdr.enabled = false; + let add = |v: f64, d: f64| (v + d).clamp(-100.0, 100.0); + s.light.exposure = (s.light.exposure + h.sdr_brightness / 100.0).clamp(-5.0, 5.0); + s.light.contrast = add(s.light.contrast, h.sdr_contrast); + s.light.highlights = add(s.light.highlights, h.sdr_highlights); + s.light.shadows = add(s.light.shadows, h.sdr_shadows); + s.light.whites = add(s.light.whites, h.sdr_whites); + s.effects.clarity = add(s.effects.clarity, h.sdr_clarity); + std::borrow::Cow::Owned(s) + } +} diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index fb145410..33570f47 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -647,6 +647,33 @@ pub fn specs() -> Vec { Ok(()) }) }), + // ---- HDR editing + cmd!( + "develop.hdr", + "HDR", + [], + None, + "{enabled?: bool (default: toggle), maxEv?: number 0..5 (headroom limit, stops above SDR white)} โ€” HDR editing: highlights above SDR white for HDR exports and displays; every SDR render uses the SDR rendition (the hdr.sdr* controls)", + has_active, + |s, p| { + let id = active(s, "develop.hdr")?; + let cur = s.develop_of(id).unwrap_or_default().hdr; + let enabled = bool_or(p, "enabled", !cur.enabled); + let max_ev = match p.get("maxEv") { + None => None, + Some(v) => Some(v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("develop.hdr", "`maxEv` must be a number"))?), + }; + let label = if enabled { "HDR On" } else { "HDR Off" }; + edit(s, "develop.hdr", label, |d| { + d.hdr.enabled = enabled; + if let Some(m) = max_ev { + d.hdr.max_ev = m.clamp(0.0, lightcraft_develop::Hdr::MAX_EV_LIMIT); + } + Ok(()) + })?; + Ok(json!({"enabled": enabled, "maxEv": s.develop_of(id).map(|d| d.hdr.max_ev)})) + } + ), // ---- geometry: Upright cmd!( "geometry.upright", diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 8ee7f9ac..6a9e9147 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -600,6 +600,11 @@ pub struct ExportOptions { /// Bits per channel: 8, 16 (PNG, TIFF), 32 (TIFF: float, linear) or 10 (AVIF); `None` = the /// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`]. pub bit_depth: Option, + /// HDR output for photos edited in HDR: JPEG as an ISO 21496-1 gain map JPEG (the SDR + /// rendition plus a gain map, so the file looks right everywhere; quality as set, `limit_kb` + /// not applied), AVIF as 10-bit Rec. 2020 PQ, 32-bit float TIFF with the highlights above SDR + /// white kept. Other formats, and photos without an HDR edit, are SDR. + pub hdr: bool, } impl Default for ExportOptions { @@ -623,6 +628,7 @@ impl Default for ExportOptions { watermark: None, color_space: OutputSpace::Srgb, bit_depth: None, + hdr: false, } } } @@ -658,6 +664,7 @@ pub const OPTION_PARAMS: &[&str] = &[ "watermark", "colorSpace", "bitDepth", + "hdr", ]; /// The keys of a `watermark` object ([`Watermark`], camelCase). @@ -789,7 +796,7 @@ impl ExportOptions { } serde_json::from_value::(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?; } - "dontEnlarge" | "removeLocation" | "background" => { + "dontEnlarge" | "removeLocation" | "background" | "hdr" => { boolean(k, v)?; } "naming" | "subfolder" | "path" | "dir" | "preset" => { @@ -922,6 +929,7 @@ impl ExportOptions { .filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()), color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space), bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8), + hdr: p.get("hdr").and_then(Value::as_bool).unwrap_or(d.hdr), } } @@ -990,8 +998,10 @@ impl ExportOptions { (ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16, (ExportFormat::Png, _) => OutputDepth::U8, (ExportFormat::Tiff, Some(8)) => OutputDepth::U8, + (ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr, (ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear, (ExportFormat::Tiff, _) => OutputDepth::U16, + (ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr, (ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16, (ExportFormat::Avif, _) => OutputDepth::U8, } @@ -1007,9 +1017,19 @@ impl ExportOptions { } } - /// The colour space the file is actually written in (AVIF: sRGB). + /// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF + /// (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`]. + pub fn hdr_output(&self) -> bool { + self.hdr && matches!((self.format, self.bit_depth), (ExportFormat::Jpeg | ExportFormat::Avif, _) | (ExportFormat::Tiff, Some(32))) + } + + /// The colour space the file is actually written in (AVIF: sRGB, or Rec. 2020 for HDR). pub fn effective_space(&self) -> OutputSpace { - if self.format == ExportFormat::Avif { OutputSpace::Srgb } else { self.color_space } + match self.format { + ExportFormat::Avif if self.hdr => OutputSpace::Rec2020, + ExportFormat::Avif => OutputSpace::Srgb, + _ => self.color_space, + } } /// Output file name for photo `p` at 1-based position `seq` in a batch (the original's @@ -1119,13 +1139,8 @@ pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result, String> { /// Like [`encode_image`], embedding `meta` (already filtered by the policy) as EXIF + XMP. pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { - let mut img = img.clone(); - output_sharpen(&mut img, o.sharpen, o.sharpen_amount); + let img = finish_8bit(img, o); let space = o.effective_space(); - if let Some(wm) = &o.watermark { - let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() }; - draw_watermark(&mut img, &wm); - } let profile = icc::write_named(named_space(space)); let exif = meta.map(lightcraft_meta::write_exif); let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); @@ -1168,6 +1183,56 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada r.map_err(|e| e.to_string()) } +/// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`. +fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 { + let mut img = img.clone(); + output_sharpen(&mut img, o.sharpen, o.sharpen_amount); + let space = o.effective_space(); + if let Some(wm) = &o.watermark { + let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() }; + draw_watermark(&mut img, &wm); + } + img +} + +/// Output sharpening and the watermark on a high-bit-depth render. +fn finish_deep(img: &DeepImage, o: &ExportOptions) -> DeepImage { + let mut img = img.clone(); + output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount); + if let Some(wm) = &o.watermark { + let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() }; + draw_watermark_deep(&mut img, &wm); + } + img +} + +/// Encode an ISO 21496-1 gain map JPEG from the SDR rendition `sdr` (8-bit, in the output space) +/// and the HDR rendition `hdr` (float linear, same space and size). The gain map is measured +/// against the base as written, so its 8-bit rounding is handed back by the map. +pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + use lightcraft_codecs::gainmap; + let base = finish_8bit(sdr, o); + let hdr = finish_deep(hdr, o); + let DeepSamples::F32(hv) = &hdr.samples else { + return Err("HDR export needs a float HDR render".into()); + }; + let (w, h) = (base.width, base.height); + if (hdr.width, hdr.height) != (w, h) || hv.len() < w * h * 3 { + return Err("HDR and SDR renders differ in size".into()); + } + let space = o.effective_space(); + let trc = space.trc(); + let sdr_lin: Vec<[f32; 3]> = base.data.iter().map(|p| [0, 1, 2].map(|c| trc.decode(p[c] as f32 / 255.0))).collect(); + let hdr_lin: &[[f32; 3]] = hv.as_chunks::<3>().0; + let (map, gm) = gainmap::compute(&sdr_lin, hdr_lin, w, h, space.luma(), &gainmap::GainMapOptions::default()).map_err(|e| e.to_string())?; + let profile = icc::write_named(named_space(space)); + let exif = meta.map(lightcraft_meta::write_exif); + let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); + let em = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; + let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; + gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, o.quality, sub, &em).map_err(|e| e.to_string()) +} + /// The codecs' name of an output space (for its ICC profile). pub fn named_space(s: OutputSpace) -> NamedSpace { match s { @@ -1204,12 +1269,7 @@ pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, met /// Encode a high-bit-depth image (see [`encode_rendered`]). pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { - let mut img = img.clone(); - output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount); - if let Some(wm) = &o.watermark { - let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() }; - draw_watermark_deep(&mut img, &wm); - } + let img = finish_deep(img, o); let profile = match img.samples { DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear), DeepSamples::U16(_) => icc::write_named(named_space(img.space)), @@ -1222,11 +1282,14 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) DeepSamples::U16(v) => EncodeImage::new(w, h, 3, Samples::U16(v)), DeepSamples::F32(v) => EncodeImage::new(w, h, 3, Samples::F32(v)), }; - let r = match o.format { - ExportFormat::Png => encode::encode_png(&e, &meta), - ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta), - ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), - f => return Err(format!("{f:?} export is 8-bit only")), + let r = match (o.format, &img.samples) { + (ExportFormat::Png, _) => encode::encode_png(&e, &meta), + (ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta), + (ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => { + lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta) + } + (ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta), + (f, _) => return Err(format!("{f:?} export is 8-bit only")), }; r.map_err(|e| e.to_string()) } @@ -1324,6 +1387,8 @@ pub struct PreparedExport { struct RenderWork { job: crate::media::RenderJob, + /// The HDR rendition of a gain map JPEG (`job` renders its SDR base). + hdr_job: Option, meta: Option, opts: ExportOptions, } @@ -1369,13 +1434,23 @@ fn prepare_guarded( ) -> Result { let p = session.catalog.photo(id).ok_or("no such photo")?; let file_name = o.file_name_for(p, seq); + // HDR output only for photos edited in HDR; the rest of the batch exports as usual + let sdr_opts; + let o = if o.hdr && !p.develop.hdr.enabled { + sdr_opts = ExportOptions { hdr: false, ..o.clone() }; + &sdr_opts + } else { + o + }; let (full_px, long) = ((p.width as usize).saturating_mul(p.height as usize), p.width.max(p.height) as usize); let (work, weight) = if o.format.is_rendered() { let (w, h) = output_size(p, o); let meta = export_metadata(p, &session.catalog, o); + let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg; let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?; let weight = render_weight(full_px, long, job.level, w.saturating_mul(h)); - (Work::Render(Box::new(RenderWork { job, meta, opts: o.clone() })), weight) + let hdr_job = if gain_map { Some(session.export_job(id, w, h, o.effective_space(), OutputDepth::F32Hdr)?) } else { None }; + (Work::Render(Box::new(RenderWork { job, hdr_job, meta, opts: o.clone() })), weight) } else { let lightcraft_catalog::Source::File { path } = &p.source else { return Err(format!("{} is a generated demo photo: it has no original file to export", p.file_name)); @@ -1412,9 +1487,16 @@ impl PreparedExport { let file_name = self.file_name; match self.work { Work::Render(w) => { - let RenderWork { job, meta, opts } = *w; + let RenderWork { job, hdr_job, meta, opts } = *w; let r = job.run().rendered?; - let bytes = encode_rendered(&r, &opts, meta.as_ref())?; + let bytes = match hdr_job { + Some(hj) => { + let hr = hj.run().rendered?; + let hdr = hr.deep.ok_or("the HDR render has no float samples")?; + encode_gain_map_jpeg(&r.image, &hdr, &opts, meta.as_ref())? + } + None => encode_rendered(&r, &opts, meta.as_ref())?, + }; Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new() }) } Work::File { path, read, packet, dng, label, size } => { diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index faba3e3c..b8636c01 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -370,3 +370,73 @@ fn exports_are_atomic_without_a_sync() { assert!(syncs_on_this_thread() > before, "the durable writer syncs"); let _ = std::fs::remove_dir_all(&dir); } + +/// The demo library's ocean sunset (a sun far above SDR white), selected and active. +fn sunset(s: &mut Session) -> lightcraft_catalog::PhotoId { + let id = s.catalog.photos().find(|p| p.meta.title == "Golden horizon").map(|p| p.id).unwrap(); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + id +} + +#[test] +fn hdr_jpeg_export_writes_a_gain_map_and_sdr_edits_stay_plain() { + use lightcraft_codecs::gainmap; + let mut s = Session::with_demo(); + let id = sunset(&mut s); + let o = ExportOptions::from_json(&json!({"longEdge": 320, "hdr": true})); + assert!(o.hdr_output()); + // no HDR edit: an ordinary JPEG even with HDR output asked for + let plain = export_photo(&mut s, id, &o, 1).unwrap(); + assert!(!gainmap::is_gain_map_jpeg(&plain.bytes)); + + let r = s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 3})).unwrap(); + assert_eq!(r["enabled"], true); + let e = export_photo(&mut s, id, &o, 1).unwrap(); + assert!(gainmap::is_gain_map_jpeg(&e.bytes)); + let gm = gainmap::read_jpeg(&e.bytes).unwrap(); + assert!(gm.meta.alternate_headroom > 0.5 && gm.meta.alternate_headroom <= 3.05, "headroom {}", gm.meta.alternate_headroom); + // the base is the SDR rendition: the same pixels as the SDR export (the decoders agree) + let a = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap(); + let b = lightcraft_codecs::decode(&plain.bytes, Default::default()).unwrap(); + assert_eq!((a.width, a.height), (b.width, b.height)); + let diff = a.image.data.iter().zip(&b.image.data).map(|(p, q)| (p[1] - q[1]).abs()).fold(0.0f32, f32::max); + assert!(diff < 0.02, "base differs from the SDR export by {diff}"); + // HDR off again: the gain map goes away + s.execute("develop.hdr", &json!({"enabled": false})).unwrap(); + assert!(!gainmap::is_gain_map_jpeg(&export_photo(&mut s, id, &o, 1).unwrap().bytes)); +} + +#[test] +fn hdr_float_tiff_keeps_highlights_above_white() { + let mut s = Session::with_demo(); + let id = sunset(&mut s); + s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap(); + let max_of = |s: &mut Session, hdr: bool| { + let o = ExportOptions::from_json(&json!({"longEdge": 240, "format": "tiff", "bitDepth": 32, "hdr": hdr})); + let e = export_photo(s, id, &o, 1).unwrap(); + let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap(); + d.image.data.iter().flat_map(|p| p.iter().copied()).fold(0.0f32, f32::max) + }; + let hdr = max_of(&mut s, true); + let sdr = max_of(&mut s, false); + assert!(sdr <= 1.0 + 1e-3, "SDR float TIFF stays in 0..1 ({sdr})"); + assert!(hdr > 1.2 && hdr <= 4.0 + 1e-3, "HDR float TIFF max {hdr}"); +} + +#[test] +fn hdr_avif_export_is_pq_rec2020() { + let mut s = Session::with_demo(); + let id = sunset(&mut s); + let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "avif", "hdr": true, "quality": 70})); + assert!(o.hdr_output()); + let sdr = export_photo(&mut s, id, &o, 1).unwrap(); + s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap(); + let hdr = export_photo(&mut s, id, &o, 1).unwrap(); + // the `colr` nclx box: BT.2020 primaries (9), PQ (16), BT.2020 NCL (9), full range + let nclx = |b: &[u8]| b.windows(4).position(|w| w == b"nclx").map(|i| b[i + 4..i + 11].to_vec()); + let c = nclx(&hdr.bytes).expect("colr nclx box"); + assert_eq!(c, vec![0, 9, 0, 16, 0, 9, 0x80]); + assert!(hdr.bytes.windows(4).any(|w| w == b"clli"), "content light level box"); + // a photo without an HDR edit exports as an ordinary (sRGB) AVIF + assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16)); +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 9c234964..beada8ad 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -337,9 +337,15 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R let effective = s.effective(); let s: &DevelopSettings = &effective; // the kernel writes 8-bit output: high-bit-depth exports (and soft proofs) render on the CPU - if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() { + // Visualize HDR needs the float HDR render: CPU too + if !enabled() + || req.depth != lightcraft_pipeline::OutputDepth::U8 + || req.proof.is_some() + || req.overlay == lightcraft_pipeline::Overlay::HdrRange + { return None; } + let s = &*lightcraft_pipeline::settings_for(s, req); let gpu = device()?; let ext = stages.map(|c| c.extension::()); let fault = take_fault(); diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index ef41bde7..acbecb60 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -131,7 +131,7 @@ impl Backend for Headless { "engine.commands" => { let mut v: Vec = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null, - "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} โ€” an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", + "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} โ€” an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", "enabled": self.session.active().is_some()})); Ok(Value::Array(v)) } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 2d1c2158..dd52da40 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -258,6 +258,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "removeLocation": {"type": "boolean"}, "colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"}, "bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"}, + "hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"}, "watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset (margin as a fraction of the short edge, 0..0.4; default 0.025), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"} }), &[], @@ -736,6 +737,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { "watermark", "colorSpace", "bitDepth", + "hdr", "preset", "background", "resize", diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 1831126c..fce3b832 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -192,6 +192,9 @@ pub struct FinishParams { pub px_per_long: f64, /// The window's offset and the whole output's size (pixels): `(0, 0, w, h)` unless windowed. pub view: [f32; 4], + /// Peak linear output relative to SDR white: 1 for SDR renders; `2^max_ev` for an HDR render + /// ([`OutputDepth::F32Hdr`] with HDR on), which keeps values above 1 instead of clipping them. + pub peak: f32, } impl FinishParams { @@ -220,10 +223,12 @@ impl FinishParams { ((s.grain.amount / 100.0) as f32 * 0.13, cell.max(0.6), (s.grain.roughness / 100.0) as f32, s.grain.seed) }); let calibration = s.section_enabled("calibration"); + let peak = s.hdr.peak(); FinishParams { + peak, calib: if calibration { crate::colorops::calibration_matrix(&s.calibration) } else { None }, shadow_tint: if calibration { (s.calibration.shadows_tint / 100.0) as f32 } else { 0.0 }, - tone: base_tone(s, info), + tone: base_tone(s, info, peak), lut: crate::lut::get(&s.profile.id).map(|l| (l, (s.profile.amount / 100.0).clamp(0.0, 2.0) as f32)), ops: ColorOps::new(s), curves: curve_luts(&s.curve), @@ -269,13 +274,15 @@ impl FinishParams { /// rendering process. This is where a new tone model branches: a retuned default tone curve (issue /// #146) is a new [`Process`] with its own arm here, and V1's arm stays as it is, so photos edited /// under V1 keep their look (`docs/process-versions.md`). CPU and GPU both take it from here. -pub fn base_tone(s: &DevelopSettings, info: &SourceInfo) -> ToneMap { +/// `peak` is the HDR render's peak relative to SDR white ([`lightcraft_develop::Hdr::peak`]); at 1.0 (SDR) +/// every arm is the SDR tone map. The camera's look is bounded at white, so HDR renders use the HDR tone map. +pub fn base_tone(s: &DevelopSettings, info: &SourceInfo, peak: f32) -> ToneMap { let l = &s.light; match s.process.process() { - Process::V1 => match info.camera_tone.as_ref().filter(|_| info.raw) { + Process::V1 => match info.camera_tone.as_ref().filter(|_| info.raw && peak <= 1.0) { Some(curve) => ToneMap::camera(curve, l.contrast, l.whites, l.blacks), - None if info.raw => ToneMap::new(l.contrast, l.whites, l.blacks), - None => ToneMap::display(l.contrast, l.whites, l.blacks), + None if info.raw => ToneMap::hdr(l.contrast, l.whites, l.blacks, peak), + None => ToneMap::display_hdr(l.contrast, l.whites, l.blacks, peak), }, } } @@ -327,6 +334,12 @@ pub(crate) fn finish_deep( let v = finish_with(p, &fp, true, |e| e.map(|v| srgb_to_linear(v.clamp(0.0, 1.0)))); DeepSamples::F32(v.into_flattened()) } + OutputDepth::F32Hdr => { + // the sRGB curve extended past 1 (it is unbounded), so values up to `peak` survive + let peak = fp.peak; + let v = finish_with(p, &fp, true, |e| e.map(|v| srgb_to_linear(v.max(0.0)).clamp(0.0, peak))); + DeepSamples::F32(v.into_flattened()) + } _ => { let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16; let v = finish_with(p, &fp, true, |e| match trc { @@ -381,6 +394,8 @@ pub(crate) fn finish_with( let aspect = vw / vh; let srgb = srgb_lut(); + let peak = fp.peak; + let hdr = peak > 1.0; let mut out = vec![T::default(); w * h]; for_rows(&mut out, w, |y, row| { for (x, px) in row.iter_mut().enumerate() { @@ -522,8 +537,8 @@ pub(crate) fn finish_with( d = d.map(|v| o + (v - o) * k); } let mx = d[0].max(d[1]).max(d[2]); - if mx > 1.0 { - let t = ((mx - 1.0) / (mx - o).max(1e-6)).clamp(0.0, 1.0); + if mx > peak { + let t = ((mx - peak) / (mx - o).max(1e-6)).clamp(0.0, 1.0); d = d.map(|v| v + (o - v) * t); } @@ -555,6 +570,9 @@ pub(crate) fn finish_with( } else { d = d.map(|c| c * f); } + } else if hdr { + // lighten towards SDR white; never pull HDR highlights down to it + d = d.map(|c| c + (1.0 - c).max(0.0) * v.amount * t * 0.85); } else { d = d.map(|c| c + (1.0 - c) * v.amount * t * 0.85); } @@ -567,7 +585,7 @@ pub(crate) fn finish_with( let mut r = match &fp.proof { Some(pp) => { let q0 = mul3(&pp.to_proof, d); - let (q, t) = gamut_map(q0, pp.luma); + let (q, t) = gamut_map_to(q0, pp.luma, peak); if pp.dest_warning && out_of_gamut(q0, t) { warn = Some(crate::output::PROOF_DEST_WARNING); } @@ -575,17 +593,37 @@ pub(crate) fn finish_with( } None => mul3(to_out, d), }; - let (mapped, t) = gamut_map(r, *out_luma); + let (mapped, t) = gamut_map_to(r, *out_luma, peak); if fp.proof.is_some_and(|pp| pp.display_warning) && warn.is_none() && out_of_gamut(r, t) { warn = Some(crate::output::PROOF_DISPLAY_WARNING); } r = mapped; // --- encode, curves, grain - let mut e = if exact { r.map(|v| linear_to_srgb(v.clamp(0.0, 1.0))) } else { r.map(|v| encode_srgb(srgb, v)) }; + let mut e = if hdr { + // HDR: the (unbounded) sRGB curve, so values above SDR white encode above 1 + r.map(|v| { + if v > 1.0 { + linear_to_srgb(v) + } else if exact { + linear_to_srgb(v.max(0.0)) + } else { + encode_srgb(srgb, v) + } + }) + } else if exact { + r.map(|v| linear_to_srgb(v.clamp(0.0, 1.0))) + } else { + r.map(|v| encode_srgb(srgb, v)) + }; if let Some(l) = curves { let e0 = e; - e = [l[0].eval(e[0]), l[1].eval(e[1]), l[2].eval(e[2])]; + e = if hdr { + // above SDR white the curves continue with slope 1 from their end point + std::array::from_fn(|k| if e[k] > 1.0 { l[k].eval(1.0) + (e[k] - 1.0) } else { l[k].eval(e[k]) }) + } else { + [l[0].eval(e[0]), l[1].eval(e[1]), l[2].eval(e[2])] + }; if fp.refine_sat < 1.0 { e = refine_saturation(e0, e, fp.refine_sat); } @@ -596,11 +634,20 @@ pub(crate) fn finish_with( let sc = fp.px_per_long as f32 / cell; let mut g = grain_noise(gx * sc, gy * sc, seed); g = g * (1.0 - rough * 0.5) + grain_noise(gx * sc * 2.3, gy * sc * 2.3, seed ^ 0x55) * rough * 0.7; - let lum = 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]; + let mut lum = 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]; + if hdr { + lum = lum.clamp(0.0, 1.0); + } let k = amt * g * (0.35 + 2.6 * lum * (1.0 - lum)); e = e.map(|v| v + k); } let e = match &p_lut { + Some((l, k)) if hdr => { + // the LUT sees SDR values; the part above SDR white is carried over + let c = e.map(|v| v.clamp(0.0, 1.0)); + let m = l.apply(c); + [e[0] + (m[0] - c[0]) * k, e[1] + (m[1] - c[1]) * k, e[2] + (m[2] - c[2]) * k] + } Some((l, k)) => { let m = l.apply(e.map(|v| v.clamp(0.0, 1.0))); [e[0] + (m[0] - e[0]) * k, e[1] + (m[1] - e[1]) * k, e[2] + (m[2] - e[2]) * k] @@ -638,13 +685,19 @@ fn mul3(m: &[[f32; 3]; 3], d: [f32; 3]) -> [f32; 3] { /// returns the mapped colour and the chroma scale used (1 = already in gamut). #[inline] pub fn gamut_map(r: [f32; 3], luma: [f32; 3]) -> ([f32; 3], f32) { - let yy = (luma[0] * r[0] + luma[1] * r[1] + luma[2] * r[2]).clamp(0.0, 1.0); + gamut_map_to(r, luma, 1.0) +} + +/// [`gamut_map`] into `0..peak` (an HDR render's range; `peak` = 1 is [`gamut_map`]). +#[inline] +pub fn gamut_map_to(r: [f32; 3], luma: [f32; 3], peak: f32) -> ([f32; 3], f32) { + let yy = (luma[0] * r[0] + luma[1] * r[1] + luma[2] * r[2]).clamp(0.0, peak); let mut t = 1.0f32; for c in r { if c < 0.0 { t = t.min(yy / (yy - c).max(1e-9)); - } else if c > 1.0 { - t = t.min((1.0 - yy) / (c - yy).max(1e-9)); + } else if c > peak { + t = t.min((peak - yy) / (c - yy).max(1e-9)); } } if t < 1.0 { (r.map(|c| yy + (c - yy) * t), t) } else { (r, 1.0) } diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 2f66a3c4..f7c33ee9 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -452,6 +452,13 @@ pub fn lin_cpu(img: &mut Rgb32f, info: &SourceInfo, p: &Plan<'_>) { redeye::apply(img, &p.eyes); } +/// The settings a render of `s` for `req` uses: the HDR edit for an [`OutputDepth::F32Hdr`] +/// render, otherwise its SDR rendition (see [`DevelopSettings::sdr_rendition`]). Every renderer +/// (CPU and GPU) goes through this, so an SDR render of an HDR edit looks the same everywhere. +pub fn settings_for<'a>(s: &'a DevelopSettings, req: &RenderRequest) -> std::borrow::Cow<'a, DevelopSettings> { + if req.depth == OutputDepth::F32Hdr { std::borrow::Cow::Borrowed(s) } else { s.sdr_rendition() } +} + /// Render `src` with settings `s`. pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Rendered { render_impl(Src::Borrowed(src), info, s, req, None) @@ -471,7 +478,9 @@ enum Src<'a> { fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, cache: Option<&StageCache>) -> Rendered { // sections switched off with their eye render as if at their defaults (issue #316) let effective = s.effective(); - let s: &DevelopSettings = &effective; + let edit: &DevelopSettings = &effective; + let rendition = settings_for(edit, req); + let s: &DevelopSettings = &rendition; // `Instant::now()` panics on wasm32-unknown-unknown: only read the clock when profiling. let lap = |what: &str, t: &mut Option| { if let Some(t) = t { @@ -543,7 +552,15 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende let mut image = image; let mask = overlay_alpha(req.overlay, &plan, &prep); visualize::apply(&mut image, req.overlay, &plan, mask.as_ref()); - let image = if plan.keep.is_some() { cut(&image) } else { image }; + let mut image = if plan.keep.is_some() { cut(&image) } else { image }; + if req.overlay == Overlay::HdrRange && edit.hdr.enabled { + // the HDR rendition of the same request says how far above SDR white each pixel goes + let hreq = RenderRequest { depth: OutputDepth::F32Hdr, overlay: Overlay::None, proof: None, ..*req }; + if let Some(hdr) = render(src_img, info, edit, &hreq).deep { + visualize::hdr_range(&mut image, &hdr, req.space.luma()); + } + lap("visualize hdr", &mut t); + } Rendered { image, histogram, deep: None } } @@ -624,6 +641,8 @@ mod tests; #[cfg(test)] mod tests_geometry; #[cfg(test)] +mod tests_hdr; +#[cfg(test)] mod tests_local; #[cfg(test)] mod tests_window; diff --git a/crates/pipeline/src/output.rs b/crates/pipeline/src/output.rs index 9fc14b3e..5acae092 100644 --- a/crates/pipeline/src/output.rs +++ b/crates/pipeline/src/output.rs @@ -246,6 +246,9 @@ pub enum OutputDepth { U16, /// 32-bit float *linear* RGB (target primaries, 0..1) in [`crate::Rendered::deep`]. F32Linear, + /// 32-bit float linear RGB (target primaries) of the HDR render: SDR white = 1, highlights up + /// to [`lightcraft_develop::Hdr::peak`]. With HDR off this is [`OutputDepth::F32Linear`]. + F32Hdr, } /// High-bit-depth RGB samples (3 per pixel, interleaved, row-major). diff --git a/crates/pipeline/src/tests_hdr.rs b/crates/pipeline/src/tests_hdr.rs new file mode 100644 index 00000000..bacc0eaa --- /dev/null +++ b/crates/pipeline/src/tests_hdr.rs @@ -0,0 +1,107 @@ +//! HDR editing: the HDR render ([`OutputDepth::F32Hdr`]) and the SDR rendition every other render uses. + +use lightcraft_develop::DevelopSettings; +use lightcraft_raster::Rgb32f; + +use crate::{DeepSamples, OutputDepth, RenderRequest, SourceInfo, render}; + +fn sunset() -> Rgb32f { + let lib = lightcraft_scenes::demo_library(); + let s = lib.iter().find(|s| s.kind == lightcraft_scenes::Kind::OceanSunset).map(|s| s.render(240, 160)); + s.unwrap_or_else(|| lib[0].render(240, 160)) +} + +fn raw() -> SourceInfo { + SourceInfo { raw: true, ..Default::default() } +} + +fn hdr_on(max_ev: f64) -> DevelopSettings { + let mut s = DevelopSettings::default(); + s.hdr.enabled = true; + s.hdr.max_ev = max_ev; + s +} + +fn deep(src: &Rgb32f, s: &DevelopSettings, depth: OutputDepth) -> Vec { + let req = RenderRequest { depth, ..RenderRequest::fit(240, 240) }; + match render(src, &raw(), s, &req).deep.map(|d| d.samples) { + Some(DeepSamples::F32(v)) => v, + other => panic!("expected float samples, got {other:?}"), + } +} + +#[test] +fn hdr_render_keeps_highlights_up_to_the_peak() { + let src = sunset(); + let v = deep(&src, &hdr_on(3.0), OutputDepth::F32Hdr); + assert!(v.iter().all(|x| x.is_finite() && (0.0..=8.0 + 1e-4).contains(x))); + let max = v.iter().copied().fold(0.0f32, f32::max); + assert!(max > 1.5, "the sun should rise above SDR white, max {max}"); + // a smaller headroom limit caps lower + let v1 = deep(&src, &hdr_on(1.0), OutputDepth::F32Hdr); + assert!(v1.iter().all(|x| *x <= 2.0 + 1e-4)); +} + +#[test] +fn hdr_midtones_match_the_sdr_render() { + let src = sunset(); + let hdr = deep(&src, &hdr_on(3.0), OutputDepth::F32Hdr); + let sdr = deep(&src, &DevelopSettings::default(), OutputDepth::F32Linear); + // pixels whose brightest channel is a midtone (bright saturated colours legitimately differ: + // SDR desaturates them at white, HDR keeps their colour) + let mut n = 0; + for (h, s) in hdr.as_chunks::<3>().0.iter().zip(sdr.as_chunks::<3>().0) { + if s.iter().all(|v| *v < 0.15) { + for (a, b) in h.iter().zip(s) { + assert!((a - b).abs() < 2e-3, "{h:?} vs {s:?}"); + } + n += 1; + } + } + assert!(n > 100, "the scene should have midtones and shadows ({n})"); +} + +#[test] +fn f32hdr_without_hdr_is_the_sdr_float_render() { + let src = sunset(); + let s = DevelopSettings::default(); + assert_eq!(deep(&src, &s, OutputDepth::F32Hdr), deep(&src, &s, OutputDepth::F32Linear)); +} + +#[test] +fn sdr_renders_of_an_hdr_edit_use_the_sdr_rendition() { + let src = sunset(); + let req = RenderRequest::fit(240, 240); + // neutral offsets: identical to the edit with HDR off + let plain = render(&src, &raw(), &DevelopSettings::default(), &req); + let on = render(&src, &raw(), &hdr_on(3.0), &req); + assert_eq!(plain.image.data, on.image.data); + // offsets apply to every SDR render + let mut s = hdr_on(3.0); + s.hdr.sdr_brightness = 50.0; + s.hdr.sdr_highlights = -40.0; + let brighter = render(&src, &raw(), &s, &req); + let expect = render(&src, &raw(), &s.sdr_rendition(), &req); + assert_eq!(brighter.image.data, expect.image.data); + assert_ne!(brighter.image.data, plain.image.data); + let r = s.sdr_rendition(); + assert!(!r.hdr.enabled && (r.light.exposure - 0.5).abs() < 1e-9 && r.light.highlights == -40.0); +} + +#[test] +fn visualize_hdr_paints_only_what_rises_above_white() { + use crate::Overlay; + use crate::visualize::HDR_BANDS; + let src = sunset(); + let req = RenderRequest { overlay: Overlay::HdrRange, ..RenderRequest::fit(240, 240) }; + let banded = |p: &[u8; 4]| HDR_BANDS.iter().any(|b| (0..3).all(|k| (p[k] as i32 - b[k] as i32).abs() < 70) && p[0] > p[2] + 40); + let on = render(&src, &raw(), &hdr_on(3.0), &req); + let n = on.image.data.iter().filter(|p| banded(p)).count(); + assert!(n > 0 && n < on.image.data.len() / 3, "{n} banded pixels"); + // the rest is grey + assert!(on.image.data.iter().filter(|p| !banded(p)).all(|p| p[0] == p[1] && p[1] == p[2])); + // HDR off: the overlay draws nothing + let off = render(&src, &raw(), &DevelopSettings::default(), &req); + let plain = render(&src, &raw(), &DevelopSettings::default(), &RenderRequest::fit(240, 240)); + assert_eq!(off.image.data, plain.image.data); +} diff --git a/crates/pipeline/src/tone.rs b/crates/pipeline/src/tone.rs index cc076669..f9602e99 100644 --- a/crates/pipeline/src/tone.rs +++ b/crates/pipeline/src/tone.rs @@ -92,6 +92,9 @@ impl CameraTone { } } +/// Scene EV over grey where [`ToneMap::hdr`] starts lifting highlights above the SDR curve. +const HDR_FROM_EV: f32 = 1.5; + #[derive(Clone, Debug)] pub struct ToneMap { lut: Vec, @@ -113,6 +116,16 @@ impl ToneMap { } /// `contrast`, `whites`, `blacks` in โˆ’100..100 (Lightroom slider units). pub fn new(contrast: f64, whites: f64, blacks: f64) -> ToneMap { + ToneMap::hdr(contrast, whites, blacks, 1.0) + } + + /// [`ToneMap::new`] for an HDR render reaching `peak` (linear, SDR white = 1; 1 = SDR exactly). + /// Below about half of SDR white the curve is the SDR one; above, the highlights gain up to + /// `peak โˆ’ 1` more along a smoothstep in scene EV, from 1.5 EV over grey to one stop past the + /// white point moved up by the headroom, so the result stays monotone, never exceeds `peak`, + /// and reaches it there (within the table's range). + pub fn hdr(contrast: f64, whites: f64, blacks: f64, peak: f32) -> ToneMap { + let peak = if peak.is_finite() { peak.max(1.0) } else { 1.0 }; let c = (contrast / 100.0) as f32; let slope = if c >= 0.0 { 1.0 + 0.55 * c } else { 1.0 + 0.4 * c }; // White point: scene luminance (after contrast) that maps to display 1.0. @@ -138,7 +151,11 @@ impl ToneMap { let k = b * 0.03; o = k + (1.0 - k) * o; } - o.clamp(0.0, 1.0) + if peak > 1.0 { + let top = (white_ev + peak.log2() + 1.0).max(HDR_FROM_EV + 0.5); + o += (peak - 1.0) * smooth(HDR_FROM_EV, top, ev * slope); + } + o.clamp(0.0, peak) }) .collect(); ToneMap { lut, chroma: NO_CHROMA } @@ -146,6 +163,14 @@ impl ToneMap { /// Tone map for display-referred sources: identity at neutral settings. pub fn display(contrast: f64, whites: f64, blacks: f64) -> ToneMap { + ToneMap::display_hdr(contrast, whites, blacks, 1.0) + } + + /// [`ToneMap::display`] for an HDR render reaching `peak`: values above SDR white (an HDR + /// source, e.g. a decoded gain-map image) pass through and roll off softly towards `peak` + /// instead of clipping at 1; SDR sources (โ‰ค 1) render as with [`ToneMap::display`]. + pub fn display_hdr(contrast: f64, whites: f64, blacks: f64, peak: f32) -> ToneMap { + let peak = if peak.is_finite() { peak.max(1.0) } else { 1.0 }; let c = (contrast / 100.0) as f32; let w = (whites / 100.0) as f32; let b = (blacks / 100.0) as f32; @@ -168,11 +193,15 @@ impl ToneMap { } let mut o = p.max(0.0).powf(2.2); // short shoulder: slope 1 at 0.95, reaching 1.0 at 1.05 - if o > 0.95 { + if peak > 1.0 && o > 0.95 { + // exponential shoulder: slope 1 at 0.95, asymptote at `peak` + let r = peak - 0.95; + o = 0.95 + r * (1.0 - (-(o - 0.95) / r).exp()); + } else if o > 0.95 { let d = (o - 0.95).min(0.1); o = 0.95 + d - d * d / 0.2; } - o.clamp(0.0, 1.0) + o.clamp(0.0, peak) }) .collect(); ToneMap { lut, chroma: NO_CHROMA } @@ -296,6 +325,51 @@ mod tests { } } + #[test] + fn hdr_is_sdr_at_peak_one_and_reaches_peak() { + for (c, w, b) in [(0.0, 0.0, 0.0), (100.0, 100.0, -100.0), (-100.0, -100.0, 100.0), (50.0, -30.0, -40.0)] { + assert_eq!(ToneMap::hdr(c, w, b, 1.0).lut(), ToneMap::new(c, w, b).lut()); + for peak in [2.0f32, 8.0, 32.0] { + let sdr = ToneMap::new(c, w, b); + let t = ToneMap::hdr(c, w, b, peak); + let mut prev = -1.0; + for i in 0..2400 { + let y = 1e-5 * 1.01f32.powi(i); + let o = t.apply(y); + assert!((0.0..=peak).contains(&o), "{c} {w} {b} {peak} at {y}: {o}"); + assert!(o >= prev - 1e-6, "{c} {w} {b} {peak} at {y}: {o} < {prev}"); + prev = o; + if y <= GREY * 2f32.powf(0.9) { + assert!((o - sdr.apply(y)).abs() < 1e-6, "midtones differ at {y}"); + } + } + assert!(t.apply(4.0) > sdr.apply(4.0)); + if (c, w, b) == (0.0, 0.0, 0.0) { + assert!(t.apply(GREY * 2f32.powf(LUT_MAX_EV)) > peak * 0.99, "{peak}"); + } + } + } + // a broken peak falls back to SDR + assert_eq!(ToneMap::hdr(0.0, 0.0, 0.0, f32::NAN).lut(), ToneMap::new(0.0, 0.0, 0.0).lut()); + } + + #[test] + fn display_hdr_passes_hdr_sources_and_keeps_sdr() { + assert_eq!(ToneMap::display_hdr(20.0, 10.0, -5.0, 1.0).lut(), ToneMap::display(20.0, 10.0, -5.0).lut()); + let t = ToneMap::display_hdr(0.0, 0.0, 0.0, 8.0); + for i in 1..=90 { + let y = i as f32 / 100.0; + assert!((t.apply(y) - y).abs() < 2e-3, "{y} -> {}", t.apply(y)); + } + let mut prev = 0.0; + for i in 1..2000 { + let o = t.apply(i as f32 / 100.0); + assert!(o >= prev - 1e-6 && o <= 8.0); + prev = o; + } + assert!(t.apply(3.0) > 2.0 && t.apply(3.0) < 3.0); + } + #[test] fn display_identity_and_monotone() { let t = ToneMap::display(0.0, 0.0, 0.0); diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index 99e45449..fc3d8390 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -33,8 +33,14 @@ pub enum Overlay { /// The evaluated alpha of mask `id` (a [`lightcraft_develop::Mask`] id), drawn as `view` in /// `color` at `opacity` (0..100; the colour views only). Mask { id: u16, view: MaskView, color: [u8; 3], opacity: u8 }, + /// Visualize HDR (photos edited in HDR): the SDR image dimmed to grey, with every pixel the HDR + /// rendition puts above SDR white painted by how far above it is ([`HDR_BANDS`]). + HdrRange, } +/// [`Overlay::HdrRange`] colours (sRGB) for 0โ€“1, 1โ€“2, 2โ€“3 and โ‰ฅ 3 stops above SDR white. +pub const HDR_BANDS: [[u8; 3]; 4] = [[255, 214, 92], [255, 150, 40], [235, 64, 52], [214, 60, 210]]; + /// How [`Overlay::Mask`] draws the mask. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum MaskView { @@ -114,6 +120,7 @@ impl Overlay { Overlay::PointColorRange(i) => (1, i as f64), Overlay::Spots(t) => (2, t as f64), Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64), + Overlay::HdrRange => (4, 0.0), } } @@ -123,6 +130,7 @@ impl Overlay { 1 => Overlay::PointColorRange(v as u8), 2 => Overlay::Spots(v.clamp(0.0, 100.0) as u8), 3 if v.is_finite() && v >= 0.0 => unpack_mask(v as u64), + 4 => Overlay::HdrRange, _ => Overlay::None, } } @@ -134,6 +142,7 @@ impl Overlay { Overlay::PointColorRange(i) => 0x1000 + i as u64, Overlay::Spots(t) => 0x2000 + t as u64, Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity), + Overlay::HdrRange => 0x4000, } } @@ -162,7 +171,8 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) { /// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]). pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) { match o { - Overlay::None => {} + // drawn by the renderer from the HDR rendition ([`hdr_range`]) + Overlay::None | Overlay::HdrRange => {} Overlay::PointColorRange(i) => { if let Some(p) = plan.settings.point_colors.get(i as usize) { point_color_range(img, &PointK::new(p)); @@ -245,6 +255,34 @@ fn point_color_range(img: &mut Rgba8, k: &PointK) { }); } +/// Draw [`Overlay::HdrRange`] on `img` (the SDR render) from `hdr` (the HDR render of the same +/// request, float linear with luminance weights `luma`): grey below SDR white, band colours above. +pub fn hdr_range(img: &mut Rgba8, hdr: &crate::DeepImage, luma: [f32; 3]) { + let crate::DeepSamples::F32(v) = &hdr.samples else { return }; + if (hdr.width, hdr.height) != (img.width, img.height) { + return; + } + let w = img.width; + for_rows(&mut img.data, w, |y, row| { + for (x, p) in row.iter_mut().enumerate() { + let i = (y * w + x) * 3; + let Some(c) = v.get(i..i + 3) else { continue }; + let lum = luma[0] * c[0] + luma[1] * c[1] + luma[2] * c[2]; + let grey = (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) * 0.55; + if lum > 1.0 { + let stops = lum.log2(); + let band = HDR_BANDS[(stops as usize).min(HDR_BANDS.len() - 1)]; + for k in 0..3 { + p[k] = (grey * 0.25 + band[k] as f32 * 0.75).round().clamp(0.0, 255.0) as u8; + } + } else { + let g = grey.round().clamp(0.0, 255.0) as u8; + (p[0], p[1], p[2]) = (g, g, g); + } + } + }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 8a867b59..b919aa2e 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -146,6 +146,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.zoom100", "---", "view.clipping", + "view.visualizeHdr", "view.softProof", "view.maskOverlay", "view.maskOverlayMode", @@ -308,6 +309,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.filmstrip" => Some(u.filmstrip), "view.histogram" => Some(u.histogram), "view.clipping" => Some(u.show_clipping), + "view.visualizeHdr" => Some(u.hdr_visualize), "view.softProof" => Some(u.soft_proof), "view.maskOverlay" => Some(u.mask_overlay), "view.maskPins" => Some(u.mask_pins), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 80df46d7..2c63a3d3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -84,6 +84,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.zoomIn", "Zoom In", Some("Cmd+="), "View"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"), ("view.clipping", "Show Clipping", Some("J"), "View"), + ("view.visualizeHdr", "Visualize HDR", None, "View"), // in grids S expands/collapses stacks (the engine command it shadows) ("view.softProof", "Soft Proofing", Some("S"), "View"), ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), @@ -589,6 +590,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // {on?}; no params toggles. Shown on photos edited in HDR (see `develop.hdr`). + app.ui.hdr_visualize = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.hdr_visualize); + Ok(json!({"on": app.ui.hdr_visualize})) + } "view.softProof" => { // {on?, space?, destWarning?, displayWarning?}; no params toggles let has = |k: &str| p.get(k).is_some(); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index d6e4b60b..880cfb7b 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1080,6 +1080,9 @@ pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcra if edit && app.ui.point_color_visualize && app.ui.flyout_open("pointColor") && app.ui.point_color < d.point_colors.len() { return Overlay::PointColorRange(app.ui.point_color as u8); } + if edit && app.ui.hdr_visualize && d.hdr.enabled { + return Overlay::HdrRange; + } Overlay::None } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 6ce43b8c..aa287c95 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -553,7 +553,16 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd); opts.bit_depth = Some(bd); } + if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif) { + let tip = "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit Rec. 2020 PQ; TIFF needs 32-bit float"; + ui.checkbox(&mut opts.hdr, "HDR output").on_hover_text(tip); + if opts.hdr && !opts.hdr_output() { + ui.label(egui::RichText::new("HDR TIFF needs 32-bit float.").color(t.text_dim)); + } + } if !rendered { + } else if opts.format == F::Avif && opts.hdr { + ui.label(egui::RichText::new("Color space: Rec. 2020 PQ (HDR AVIF)").color(t.text_dim)); } else if opts.format == F::Avif { ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim)); } else { diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ed7c7704..56f405ea 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -168,10 +168,23 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { divider(ui); section(app, ui, &d, "light", "Light", |app, ui, d| { + hdr_row(app, ui, d); for c in ["light.exposure", "light.contrast", "light.highlights", "light.shadows", "light.whites", "light.blacks"] { control(app, ui, d, c, true); } ui.add_space(6.0); + if d.hdr.enabled { + let open = app.ui.flyout_open("sdrRendition"); + if flyout_row(ui, "sdrRendition", "SDR Rendition", Icon::Sliders, open).clicked() { + app.ui.toggle_flyout("sdrRendition"); + } + if open { + for c in ["hdr.sdrBrightness", "hdr.sdrContrast", "hdr.sdrHighlights", "hdr.sdrShadows", "hdr.sdrWhites", "hdr.sdrClarity"] { + control(app, ui, d, c, true); + } + ui.add_space(6.0); + } + } let open = app.ui.flyout_open("curve"); if flyout_row(ui, "curve", crate::i18n::tr("Curve"), Icon::Curve, open).clicked() { app.ui.toggle_flyout("curve"); @@ -1072,6 +1085,35 @@ fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) }); } +// ------------------------------------------------------------------------------ HDR + +/// The Light panel's HDR row: the HDR switch and Visualize HDR, then (HDR on) the headroom limit. +fn hdr_row(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + let mut on = d.hdr.enabled; + if ui.checkbox(&mut on, "HDR").on_hover_text("Edit in HDR: highlights above SDR white, for HDR exports and displays").changed() { + let _ = app.run("develop.hdr", json!({"enabled": on})); + } + if d.hdr.enabled { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut v = app.ui.hdr_visualize; + if ui + .checkbox(&mut v, "Visualize HDR") + .on_hover_text("Grey below SDR white; yellow, orange, red, magenta up to 1, 2, 3 and over 3 stops above it") + .changed() + { + app.ui.hdr_visualize = v; + } + }); + } + }); + }); + if d.hdr.enabled { + control(app, ui, d, "hdr.maxEv", true); + } +} + // ------------------------------------------------------------------------------ colour grading fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 65e9b7eb..b178a167 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -440,6 +440,8 @@ pub struct UiState { pub point_color: usize, /// Point Color "Visualize range": the selected sample's range in colour, the rest grey. pub point_color_visualize: bool, + /// Light panel "Visualize HDR": grey below SDR white, colour bands above (HDR edits). + pub hdr_visualize: bool, /// Red Eye panel: selected correction, and whether new ones are pet eyes. pub eye: usize, pub eye_pet: bool, @@ -854,6 +856,7 @@ impl Default for UiState { remove_opacity: 100.0, point_color: 0, point_color_visualize: false, + hdr_visualize: false, eye: 0, eye_pet: false, visualize_spots: false, diff --git a/docs/parity.md b/docs/parity.md index d851413f..3d09ef1a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -26,7 +26,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | C. Views & navigation (VIEW) | 15 | 1 | 1 | 0 | 8/9 (89%) | 4/4 (100%) | | D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | -| F. Edit panel โ€” global adjustments (EDIT) | 42 | 2 | 4 | 1 | 28/28 (100%) | 13/14 (93%) | +| F. Edit panel โ€” global adjustments (EDIT) | 43 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) | | G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) | | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 7 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | @@ -37,9 +37,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | | O. Merge (MERGE) | 4 | 0 | 0 | 0 | โ€” | โ€” | | P. Enhance (ENH) | 0 | 0 | 2 | 0 | โ€” | โ€” | -| Q. HDR (HDR) | 0 | 0 | 5 | 0 | โ€” | โ€” | +| Q. HDR (HDR) | 2 | 3 | 0 | 0 | โ€” | โ€” | | R. Video (VID) | 0 | 0 | 5 | 1 | โ€” | 0/2 (0%) | -| S. Export (EXP) | 15 | 1 | 2 | 0 | 7/7 (100%) | 7/7 (100%) | +| S. Export (EXP) | 15 | 2 | 1 | 0 | 7/7 (100%) | 7/7 (100%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | โ€” | โ€” | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | โ€” | 0/1 (0%) | | V. Preferences (PREF) | 7 | 1 | 2 | 2 | 1/1 (100%) | 5/5 (100%) | @@ -239,7 +239,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. |---|---|---|---|---|---| | LR-EDIT-AUTO | Auto settings | P0 | โœ… | `cmd:develop.auto`, `crates/pipeline/src/auto.rs` | a Lightroom-like recipe calibrated black-box on Lightroom's Auto results for 24 Sony raws: Exposure from the scene's scene-linear log-average luminance (Lightroom's observed slope, -0.41 EV per EV), Highlights -66 and Shadows +52 (Lightroom's average Auto values), Contrast +68 and Vibrance +30 fitted on our scales. Held out one photo at a time: on the 16 photos whose start Sony's DRO doesn't brighten, a median dE76 of 6.4 from Lightroom's Auto results against 8.3 unedited; on all 24, 7.7 against 7.9 (issues #318, #209). Calibrated on Sony files only; Whites and Blacks stay 0 | | LR-EDIT-BW | Black & white | P0 | โœ… | `cmd:develop.treatment` | | -| LR-EDIT-HDR-MODE | HDR editing | P2 | โฌœ | | | +| LR-EDIT-HDR-MODE | HDR editing | P2 | โœ… | `cmd:develop.hdr`, `ctl:hdr.maxEv`, `crates/ui-egui/src/panels/edit.rs` (`hdr_row`) | HDR switch in the Light panel; see Q. HDR | | LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | โœ… | `ctl:light.exposure` | | | LR-EDIT-LIGHT-CONTRAST | Contrast | P0 | โœ… | `ctl:light.contrast` | | | LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | โœ… | `ctl:light.highlights`, `crates/raw/src/highlight.rs` | sensor-clipped highlights stay neutral when Highlights (or Exposure, Whites) darkens them: fully clipped content is rebuilt to the neutral of the colour model applied after it, partly clipped content takes its colour from reliable unclipped content nearby (not from dark objects or lens fringes beside it) and white where there is none (#523) | @@ -424,11 +424,11 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-HDR-EDIT | HDR editing | P2 | โฌœ | | | -| LR-HDR-SDRPREVIEW | SDR preview of HDR | P2 | โฌœ | | | -| LR-HDR-VISUALIZE | Visualize HDR range | P2 | โฌœ | | | -| LR-HDR-LIMIT | HDR headroom limit | P2 | โฌœ | | | -| LR-HDR-EXPORT | HDR export | P2 | โฌœ | | | +| LR-HDR-EDIT | HDR editing | P2 | ๐ŸŸก | `cmd:develop.hdr`, `crates/pipeline/src/tone.rs` (`ToneMap::hdr`), `crates/pipeline/src/tests_hdr.rs` | HDR tone map (SDR curve through the midtones, highlights up to the headroom limit) and float HDR render, CPU only; no on-screen HDR display yet (LR-VIEW-HDR-DISPLAY); HDR settings not yet read from crs XMP | +| LR-HDR-SDRPREVIEW | SDR preview of HDR | P2 | ๐ŸŸก | `ctl:hdr.sdr*` | SDR rendition sliders (brightness, contrast, highlights, shadows, whites, clarity) used by every SDR view and export; no separate SDR-preview toggle until HDR display lands | +| LR-HDR-VISUALIZE | Visualize HDR range | P2 | โœ… | `cmd:view.visualizeHdr`, `crates/pipeline/src/visualize.rs` (`hdr_range`) | grey below SDR white, four colour bands by stops above it | +| LR-HDR-LIMIT | HDR headroom limit | P2 | โœ… | `ctl:hdr.maxEv`, `cmd:develop.hdr` | 0 to 5 stops | +| LR-HDR-EXPORT | HDR export | P2 | ๐ŸŸก | `cmd:app.export`, `crates/codecs/src/gainmap.rs`, `crates/engine/src/export.rs` (`encode_gain_map_jpeg`) | `hdr` export option: ISO 21496-1 gain map JPEG (plus Adobe hdrgm and Apple XMP), 10-bit Rec. 2020 PQ AVIF, 32-bit float TIFF; no HLG yet; no HEIC (no permissive HEVC encoder) | ## R. Video (VID) @@ -452,7 +452,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EXP-BITDEPTH | Bit depth | P1 | โœ… | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | | LR-EXP-COMPRESSION | TIFF compression | P1 | โœ… | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | | | LR-EXP-COLORSPACE | Output colour space | P0 | โœ… | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC) | -| LR-EXP-HDR | HDR output | P2 | โฌœ | | | +| LR-EXP-HDR | HDR output | P2 | ๐ŸŸก | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ AVIF, float TIFF; no HDR presets yet | | LR-EXP-SHARPEN | Output sharpening | P1 | โœ… | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | | LR-EXP-METADATA | Metadata policy | P1 | โœ… | `cmd:app.export` (`metadata`, `removeLocation`) | | | LR-EXP-WATERMARK | Watermark | P1 | โœ… | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor`, `inset` (fraction of the short edge, 0..0.4), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183) |