From 7828f93c3263e77fbbc7d52bdb28cfa6734df606 Mon Sep 17 00:00:00 2001 From: akvaithi Date: Mon, 5 Oct 2026 14:55:59 -0500 Subject: [PATCH] LR-HDR-EXPORT: HDR AVIF (10-bit Rec. 2020 PQ) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `encode_avif_pq`: linear Rec. 2020 with SDR white at 1.0 → PQ (SDR white at 203 cd/m², BT.2408), BT.2020 NCL YCbCr 4:4:4, 10-bit, with CICP nclx (9/16/9, full range) and a clli box. ravif fixes the sRGB curve, so this drives the rav1e + avif-serialize it already uses (no new crates). Export: AVIF with `hdr` renders the HDR edit in Rec. 2020 and writes PQ. Photos without an HDR edit export as before, in every format. CoreImage decodes the file as HDR (same 3.1 % of the frame above SDR white as the gain map JPEG of the same edit). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + Cargo.toml | 3 + README.md | 2 +- ROADMAP.md | 4 +- crates/codecs/Cargo.toml | 4 +- crates/codecs/README.md | 2 +- crates/codecs/src/encode.rs | 135 +++++++++++++++++++++++++++ crates/codecs/src/lib.rs | 3 +- crates/engine/src/export.rs | 43 ++++++--- crates/engine/src/tests_export.rs | 18 ++++ crates/ui-egui/src/panels/dialogs.rs | 6 +- docs/parity.md | 4 +- 12 files changed, 203 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 54f556b2..3661f762 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3026,6 +3026,7 @@ dependencies = [ name = "lightcraft-codecs" version = "0.5.0" dependencies = [ + "avif-serialize", "bytemuck", "image", "image-webp", @@ -3039,6 +3040,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 c1efeaec..98491ad1 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the | 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: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG or 32-bit float TIFF | ✅ · HDR display, AVIF HDR ⬜ | +| 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 | ✅ | diff --git a/ROADMAP.md b/ROADMAP.md index fe5197f9..662f9c51 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) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ | -| **HDR & video** | ~25% | HDR editing (headroom limit, SDR rendition, Visualize HDR); gain map JPEG and float TIFF export | On-screen HDR display, AVIF PQ/HLG export, HDR settings from crs XMP; 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 and German 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 @@ -89,7 +89,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 18d00ac3..7784175a 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 575a9170..7c032566 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -52,7 +52,7 @@ 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 | **no** (sniff only) | — | see gaps | | 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 | diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 7552eede..e3d83ac3 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -363,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 || rgb.len() < n * 3 { + 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/lib.rs b/crates/codecs/src/lib.rs index b0cdd5a0..ce93932a 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -35,7 +35,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/engine/src/export.rs b/crates/engine/src/export.rs index 0ba8164a..60600b8e 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -601,8 +601,9 @@ pub struct ExportOptions { /// 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), 32-bit float TIFF with the - /// highlights above SDR white kept. Other formats, and photos without an HDR edit, are 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, } @@ -1000,6 +1001,7 @@ impl ExportOptions { (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, } @@ -1015,15 +1017,19 @@ impl ExportOptions { } } - /// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map) or - /// 32-bit float TIFF with [`ExportOptions::hdr`]. + /// 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::Tiff, Some(32))) + 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). + /// 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 @@ -1276,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()) } @@ -1429,10 +1438,18 @@ 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 work = if o.format.is_rendered() { let (w, h) = output_size(p, o); let meta = export_metadata(p, o); - let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg && p.develop.hdr.enabled; + 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 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() })) diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index df244a12..b8636c01 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -422,3 +422,21 @@ fn hdr_float_tiff_keeps_highlights_above_white() { 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/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 9a462fc0..0f33e16d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -532,14 +532,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) { - let tip = "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays); TIFF needs 32-bit float"; + 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/docs/parity.md b/docs/parity.md index 658984e6..8291675b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -427,7 +427,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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), 32-bit float TIFF; AVIF PQ/HLG not yet; no HEIC (no permissive HEVC encoder) | +| 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) @@ -451,7 +451,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 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, float TIFF; AVIF later | +| 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) |