diff --git a/apps/lightcraft/src/hdr_display.rs b/apps/lightcraft/src/hdr_display.rs new file mode 100644 index 000000000..2983e9a21 --- /dev/null +++ b/apps/lightcraft/src/hdr_display.rs @@ -0,0 +1,174 @@ +//! HDR display output (LR-VIEW-HDR-DISPLAY): whether the window can show the range above SDR +//! white, and with which surface format. Pure decisions, unit-tested here; the window always +//! falls back to SDR when any condition is missing, so an HDR edit is never shown wrong. +//! +//! HDR needs three things: a platform path (macOS EDR via an extended-range float Metal layer, +//! Windows HDR via scRGB float or HDR10 swap chains, Wayland via the colour-management protocol), +//! a surface format that carries values above 1.0 (`Rgba16Float`) or a 10-bit PQ signal +//! (`Rgb10a2Unorm`), and a renderer that actually configured that format. eframe 0.36 picks the +//! surface format itself (8-bit only, `egui_wgpu::preferred_framebuffer_format`), so today the +//! choice reports SDR with the reason; the moment the renderer's target is an HDR format the same +//! decision turns HDR on. + +use eframe::wgpu::TextureFormat; + +/// Windowing platform, as far as HDR output is concerned. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Platform { + MacOs, + Windows, + Wayland, + X11, + Web, + Other, +} + +impl Platform { + /// The platform this build runs on (`wayland` from the session environment on Linux / BSD). + pub fn current() -> Self { + if cfg!(target_os = "macos") { + Self::MacOs + } else if cfg!(target_os = "windows") { + Self::Windows + } else if cfg!(target_arch = "wasm32") { + Self::Web + } else if std::env::var_os("WAYLAND_DISPLAY").is_some() { + Self::Wayland + } else if std::env::var_os("DISPLAY").is_some() { + Self::X11 + } else { + Self::Other + } + } + + /// Whether the platform has an HDR compositing path wgpu can reach at all. + fn has_hdr_path(self) -> bool { + matches!(self, Self::MacOs | Self::Windows | Self::Wayland) + } +} + +/// How HDR values reach the display. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HdrEncoding { + /// Linear float, SDR white at 1.0 and the headroom above it (macOS EDR, Windows scRGB, + /// Wayland extended linear). + ExtendedLinear, + /// 10-bit BT.2100 PQ (Windows HDR10, Wayland PQ). + Pq10, +} + +/// The display output decision. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DisplayOutput { + Sdr { reason: &'static str }, + Hdr { format: TextureFormat, encoding: HdrEncoding }, +} + +impl DisplayOutput { + #[cfg_attr(not(test), allow(dead_code))] // read by the HDR view once the surface can be HDR + pub fn is_hdr(self) -> bool { + matches!(self, Self::Hdr { .. }) + } +} + +/// The encoding a surface format carries on `platform`, if it is an HDR one. +fn encoding_of(format: TextureFormat, platform: Platform) -> Option { + match format { + TextureFormat::Rgba16Float => Some(HdrEncoding::ExtendedLinear), + // macOS takes HDR through EDR floats only + TextureFormat::Rgb10a2Unorm if platform != Platform::MacOs => Some(HdrEncoding::Pq10), + _ => None, + } +} + +/// The best HDR surface format among those a surface offers (`available`), for `platform`: +/// float first (keeps SDR white at 1.0 and needs no PQ encode), then 10-bit PQ. `None` = SDR only. +/// (For configuring the surface once eframe lets the app pick its format.) +#[cfg_attr(not(test), allow(dead_code))] +pub fn preferred_hdr_format(available: &[TextureFormat], platform: Platform) -> Option { + if !platform.has_hdr_path() { + return None; + } + [TextureFormat::Rgba16Float, TextureFormat::Rgb10a2Unorm].into_iter().find(|f| available.contains(f) && encoding_of(*f, platform).is_some()) +} + +/// The decision for the window: `target` is the format the renderer configured (`None`: the glow +/// renderer or no wgpu state), `display_headroom` the display's current peak over SDR white as the +/// OS reports it (`None`: unknown, `Some(1.0)`: an SDR display or HDR switched off in the OS), +/// `disabled` the user's opt-out (`LIGHTCRAFT_HDR_DISPLAY=0`). +pub fn choose(target: Option, platform: Platform, display_headroom: Option, disabled: bool) -> DisplayOutput { + if disabled { + return DisplayOutput::Sdr { reason: "HDR display is switched off" }; + } + if !platform.has_hdr_path() { + return DisplayOutput::Sdr { reason: "this windowing system has no HDR output" }; + } + let Some(format) = target else { + return DisplayOutput::Sdr { reason: "the window renderer is not wgpu" }; + }; + let Some(encoding) = encoding_of(format, platform) else { + return DisplayOutput::Sdr { reason: "the window surface is 8-bit" }; + }; + match display_headroom { + Some(h) if h.is_finite() && h > 1.0 => DisplayOutput::Hdr { format, encoding }, + Some(_) => DisplayOutput::Sdr { reason: "the display is in SDR mode" }, + None => DisplayOutput::Sdr { reason: "the display's HDR headroom is unknown" }, + } +} + +/// The decision for this start, logged once (the window renderer's `target` format). +pub fn report(target: Option) -> DisplayOutput { + let disabled = std::env::var("LIGHTCRAFT_HDR_DISPLAY").ok().is_some_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no")); + // no OS headroom query is wired yet: unknown, so SDR unless everything else is HDR-ready + let out = choose(target, Platform::current(), None, disabled); + match out { + DisplayOutput::Sdr { reason } => log::info!("display: SDR ({reason})"), + DisplayOutput::Hdr { format, encoding } => log::info!("display: HDR {encoding:?} ({format:?})"), + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use TextureFormat as F; + + #[test] + fn eight_bit_surfaces_are_sdr() { + for p in [Platform::MacOs, Platform::Windows, Platform::Wayland] { + for f in [F::Bgra8Unorm, F::Bgra8UnormSrgb, F::Rgba8Unorm] { + assert!(!choose(Some(f), p, Some(4.0), false).is_hdr(), "{p:?} {f:?}"); + } + } + } + + #[test] + fn hdr_needs_platform_format_and_headroom() { + let hdr = DisplayOutput::Hdr { format: F::Rgba16Float, encoding: HdrEncoding::ExtendedLinear }; + assert_eq!(choose(Some(F::Rgba16Float), Platform::MacOs, Some(2.0), false), hdr); + assert_eq!( + choose(Some(F::Rgb10a2Unorm), Platform::Windows, Some(4.0), false), + DisplayOutput::Hdr { format: F::Rgb10a2Unorm, encoding: HdrEncoding::Pq10 } + ); + // any missing condition falls back to SDR + assert!(!choose(Some(F::Rgba16Float), Platform::X11, Some(4.0), false).is_hdr()); + assert!(!choose(Some(F::Rgba16Float), Platform::Web, Some(4.0), false).is_hdr()); + assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(1.0), false).is_hdr()); + assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, None, false).is_hdr()); + assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(f32::NAN), false).is_hdr()); + assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(2.0), true).is_hdr()); + assert!(!choose(None, Platform::Windows, Some(4.0), false).is_hdr()); + // macOS has no PQ swap chain path + assert!(!choose(Some(F::Rgb10a2Unorm), Platform::MacOs, Some(4.0), false).is_hdr()); + } + + #[test] + fn preferred_format_picks_float_then_pq() { + let all = [F::Bgra8Unorm, F::Rgb10a2Unorm, F::Rgba16Float]; + assert_eq!(preferred_hdr_format(&all, Platform::Windows), Some(F::Rgba16Float)); + assert_eq!(preferred_hdr_format(&[F::Bgra8Unorm, F::Rgb10a2Unorm], Platform::Wayland), Some(F::Rgb10a2Unorm)); + assert_eq!(preferred_hdr_format(&[F::Bgra8Unorm, F::Rgb10a2Unorm], Platform::MacOs), None); + assert_eq!(preferred_hdr_format(&all, Platform::X11), None); + assert_eq!(preferred_hdr_format(&[], Platform::MacOs), None); + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index e308ba74a..108534889 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -29,6 +29,7 @@ mod colord; mod control_server; mod dialog_filter; mod editors; +mod hdr_display; mod logging; #[cfg(target_os = "macos")] mod native_menu; @@ -848,6 +849,8 @@ fn main() -> eframe::Result { // (never required: without it, AI masks offer to download it; see docs/ai-masks.md) // and the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too) session = session.with_default_sam3_model(); + // HDR display: SDR unless the surface, platform and display all allow HDR + hdr_display::report(cc.wgpu_render_state.as_ref().map(|r| r.target_format)); let mut app = LightcraftApp::new(session, services(cc, app_log_file.as_deref())); if let Some(ui) = prefs { app.ui = ui; diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 9ac54fca5..6ea5866b7 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -53,7 +53,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) | yes (jixel, `encode_jxl`, feature `jxl`, native only): lossy VarDCT / lossless Modular, 8/16-bit, effort (fast / normal), progressive; HDR as 16-bit Rec. 2020 PQ | decode: enum colour → rendered straight to linear Rec.2020 (XYB via linear sRGB primaries); ICC → our ICC path; orientation applied by the decoder (reported as 1). Encode: lossy files declare an enum encoding (sRGB, Display P3, Rec. 2020 BT.709 curve, Rec. 2020 PQ), lossless ones embed the ICC profile; EXIF / XMP in `Exif` / `xml ` boxes | -| 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`) | +| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_hdr` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) or HLG (`hlg_signal`, SDR white at 0.75) | | 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 | diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 6333a9c8d..725980e53 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -394,11 +394,41 @@ pub fn pq_decode(e: f32) -> f32 { ((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1) * 10_000.0 } +/// Transfer curve of an HDR file: SMPTE ST 2084 (PQ, absolute) or ITU-R BT.2100 HLG (relative). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum HdrTransfer { + #[default] + Pq, + Hlg, +} + +/// Nominal peak of the HLG reference display, cd/m² (ITU-R BT.2100 / BT.2408). +pub const HLG_REFERENCE_PEAK_NITS: f32 = 1000.0; + +/// Display-referred linear RGB (SDR white = 1.0, Rec. 2020) → HLG signal 0..1, per BT.2100: +/// display light normalised to the 1000 cd/m² reference display, the inverse OOTF (system gamma +/// 1.2, applied on luminance so hue is kept), then the HLG OETF. SDR white lands at signal 0.75, +/// as BT.2408 asks. Non-finite or negative inputs count as 0. +pub fn hlg_signal(rgb: [f32; 3]) -> [f32; 3] { + const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; + const GAMMA: f32 = 1.2; + let fd = rgb.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * (HDR_REFERENCE_WHITE_NITS / HLG_REFERENCE_PEAK_NITS)); + let yd = (K[0] * fd[0] + K[1] * fd[1] + K[2] * fd[2]).min(1.0); + let scale = if yd > 0.0 { yd.powf((1.0 - GAMMA) / GAMMA) } else { 0.0 }; + fd.map(|v| lightcraft_color::transfer::hlg_encode(f64::from((v * scale).clamp(0.0, 1.0))).clamp(0.0, 1.0) as f32) +} + +/// Encode an HDR AVIF with the PQ curve (see [`encode_avif_hdr`]). +pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result> { + encode_avif_hdr(width, height, rgb, quality, speed, meta, HdrTransfer::Pq) +} + /// 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. +/// `transfer` curve: PQ with SDR white at [`HDR_REFERENCE_WHITE_NITS`] and a content light level +/// box, or HLG ([`hlg_signal`], no light level box: HLG is relative to the display). /// `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> { +pub fn encode_avif_hdr(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta, transfer: HdrTransfer) -> 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) { @@ -420,7 +450,10 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: 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 [r, g, b] = match transfer { + HdrTransfer::Pq => nits.map(pq_encode), + HdrTransfer::Hlg => hlg_signal([c[0], c[1], c[2]]), + }; 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; @@ -446,11 +479,15 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: ec.pixel_range = PixelRange::Full; ec.color_description = Some(ColorDescription { color_primaries: ColorPrimaries::BT2020, - transfer_characteristics: TransferCharacteristics::SMPTE2084, + transfer_characteristics: match transfer { + HdrTransfer::Pq => TransferCharacteristics::SMPTE2084, + HdrTransfer::Hlg => TransferCharacteristics::HLG, + }, 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.content_light = + (transfer == HdrTransfer::Pq).then_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; @@ -488,10 +525,15 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: 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_transfer_characteristics(match transfer { + HdrTransfer::Pq => c::TransferCharacteristics::Smpte2084, + HdrTransfer::Hlg => c::TransferCharacteristics::Hlg, + }) .set_color_primaries(c::ColorPrimaries::Bt2020) - .set_full_color_range(true) - .set_content_light_level(cll.0, cll.1); + .set_full_color_range(true); + if transfer == HdrTransfer::Pq { + a.set_content_light_level(cll.0, cll.1); + } if let Some(exif) = meta.exif { a.set_exif(exif.to_vec()); } @@ -499,7 +541,7 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: } #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] { - let _ = (quality, speed, meta); + let _ = (quality, speed, meta, transfer); Err(Error::Encode("AVIF encoding is not available in this build".into())) } } @@ -586,3 +628,37 @@ fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result= prev); + prev = v; + } + } + + #[test] + fn hdr_avif_rejects_short_buffers() { + let m = EncodeMeta::default(); + assert!(encode_avif_hdr(4, 4, &[0.5; 10], 80, 10, &m, HdrTransfer::Hlg).is_err()); + assert!(encode_avif_hdr(0, 4, &[], 80, 10, &m, HdrTransfer::Hlg).is_err()); + } +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 76a4950ae..e524cffe8 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -39,8 +39,8 @@ mod tiff_codec; mod webp; pub use encode::{ - ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg, - encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless, + ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, HLG_REFERENCE_PEAK_NITS, HdrTransfer, Samples, TiffCompression, + encode_avif, encode_avif_hdr, encode_avif_pq, encode_jpeg, encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless, hlg_signal, }; pub use jxl_encode::{JxlColour, JxlEffort, JxlOptions, encode_jxl}; pub use sniff::{Format, sniff}; diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index c325f719e..e53dfcbc4 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -626,6 +626,32 @@ pub enum MetadataPolicy { None, } +/// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or +/// HLG (relative to the display; BT.2100 with SDR white at signal 0.75). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HdrCurve { + #[default] + Pq, + Hlg, +} + +impl HdrCurve { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "pq" => Some(Self::Pq), + "hlg" => Some(Self::Hlg), + _ => None, + } + } + fn codec(self) -> lightcraft_codecs::HdrTransfer { + match self { + Self::Pq => lightcraft_codecs::HdrTransfer::Pq, + Self::Hlg => lightcraft_codecs::HdrTransfer::Hlg, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct ExportOptions { @@ -684,6 +710,8 @@ pub struct ExportOptions { pub add_to_photos: bool, /// The Photos album they go into, made when missing (empty = none). pub photos_album: String, + /// The curve of an HDR AVIF: PQ (default) or HLG. + pub hdr_transfer: HdrCurve, } impl Default for ExportOptions { @@ -713,6 +741,7 @@ impl Default for ExportOptions { jxl_progressive: false, add_to_photos: false, photos_album: String::new(), + hdr_transfer: HdrCurve::Pq, } } } @@ -754,6 +783,7 @@ pub const OPTION_PARAMS: &[&str] = &[ "jxlProgressive", "addToPhotos", "photosAlbum", + "hdrTransfer", ]; /// Why an export can't add its files to Apple Photos on this platform (`None`: it can, when the @@ -926,6 +956,7 @@ impl ExportOptions { } "sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?, "sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?, + "hdrTransfer" => one_of(k, v, &["pq", "hlg"])?, "conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?, "tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?, "jxlEffort" => one_of(k, v, &["fast", "normal"])?, @@ -1064,6 +1095,7 @@ impl ExportOptions { jxl_progressive: p.get("jxlProgressive").and_then(Value::as_bool).unwrap_or(d.jxl_progressive), add_to_photos: p.get("addToPhotos").and_then(Value::as_bool).unwrap_or(d.add_to_photos), photos_album: s("photosAlbum").map_or(d.photos_album, |a| a.trim().to_string()), + hdr_transfer: s("hdrTransfer").and_then(HdrCurve::parse).unwrap_or(d.hdr_transfer), } } @@ -1462,7 +1494,7 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) return lightcraft_raw::write_rendered_dng(img.width, img.height, v, &img.space.rgb_space(), &m, xmp.clone()).map_err(|e| e.to_string()); } (ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => { - lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta) + lightcraft_codecs::encode_avif_hdr(w, h, v, o.quality, 8, &meta, o.hdr_transfer.codec()) } (ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta), (ExportFormat::Jxl, DeepSamples::F32(_)) if img.space == OutputSpace::Rec2020 => { diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index b57bd471b..592f7349d 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -455,6 +455,21 @@ fn hdr_avif_export_is_pq_rec2020() { assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16)); } +#[test] +fn hdr_avif_export_hlg() { + let mut s = Session::with_demo(); + let id = sunset(&mut s); + let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "avif", "hdr": true, "hdrTransfer": "hlg", "quality": 70})); + assert_eq!(o.hdr_transfer, crate::export::HdrCurve::Hlg); + assert_eq!(o.to_json()["hdrTransfer"], "hlg"); + s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap(); + let hdr = export_photo(&mut s, id, &o, 1).unwrap(); + // nclx: BT.2020 primaries (9), HLG (18), BT.2020 NCL (9), full range; no content light level box + let i = hdr.bytes.windows(4).position(|w| w == b"nclx").expect("colr nclx box"); + assert_eq!(hdr.bytes[i + 4..i + 11].to_vec(), vec![0, 9, 0, 18, 0, 9, 0x80]); + assert!(!hdr.bytes.windows(4).any(|w| w == b"clli")); +} + /// "Remove location info" holds for the file-copying formats too: the sidecar and the DNG's /// packet carry no location, and an original that embeds GPS is refused rather than copied. #[test] diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 94c180aa5..b71e3d162 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -262,6 +262,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB; lossy JPEG XL writes adobeRgb/proPhoto as rec2020). 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), PSD 8|16 (default 16), AVIF 8|10, JPEG XL 8|16; 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, PNG and JPEG XL as 16-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"}, + "hdrTransfer": {"type": "string", "enum": ["pq", "hlg"], "description": "Transfer curve of HDR AVIF output (default pq): pq = SMPTE ST 2084, hlg = ITU-R BT.2100 hybrid log-gamma"}, "addToPhotos": {"type": "boolean", "description": "macOS: after writing the files, add them to Apple Photos (Apple Events to Photos' import; the first time, macOS asks the user to allow LightCraft to control Photos). The outcome is `applePhotos` in the result, the import job: {job, running, requested, album, imported?, ids?, warning?, error?}; headless waits for Photos, the desktop app returns the running job (outcome: command export.photosImports {job}). The files are written either way. An error before exporting on other platforms, or while another import is reserved or running"}, "photosAlbum": {"type": "string", "description": "With addToPhotos: the album to add them to, a top-level Photos album of exactly this name, made when missing (several of that name: nothing is added); default none"}, "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), insetX (margin from the left or right edge as a fraction of the short edge, 0..0.4; default 0.025), insetY (margin from the top or bottom edge, same units), inset (sets both), rotation (degrees clockwise: 0|90|180|270; default 0; the watermark is sized before it is turned), 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"} @@ -743,6 +744,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { "colorSpace", "bitDepth", "hdr", + "hdrTransfer", "preset", "background", "resize", diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 95ee3be7a..08e3bd670 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -730,10 +730,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if opts.hdr && !opts.hdr_output() { ui.label(egui::RichText::new("HDR TIFF needs 32-bit float.").color(t.text_dim)); } + if opts.hdr && opts.format == F::Avif { + use lightcraft_engine::export::HdrCurve as H; + choices(ui, "HDR curve", "exportHdrCurve", &[(H::Pq, "PQ"), (H::Hlg, "HLG")], &mut opts.hdr_transfer); + } } 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)); + let curve = if opts.hdr_transfer == lightcraft_engine::export::HdrCurve::Hlg { "HLG" } else { "PQ" }; + ui.label(egui::RichText::new(format!("Color space: Rec. 2020 {curve} (HDR AVIF)")).color(t.text_dim)); } else if opts.format == F::Png && opts.hdr { ui.label(egui::RichText::new("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)").color(t.text_dim)) .on_hover_text("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map)."); diff --git a/docs/gaps.md b/docs/gaps.md index 93008f3b5..39e6c6486 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -29,7 +29,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | -| 14 | **HDR display** (EDR / Windows HDR); HLG; the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read) | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | +| 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | | 15 | **Print module**: custom packages, printer colour management (contact-sheet PDF only) | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | @@ -82,6 +82,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-12 | minor | Gap 27: the CPU ↔ GPU equivalence suite passes on an AMD Radeon 8060S (Vulkan, Linux), the first non-Apple GPU verified; 15–30 h → 13–27 h; gap not closed | | 2026-10-12 | minor | Gap 25: Render to DNG lands as an export format; lossy DNG and AVIF import remain | | 2026-10-12 | minor | Gap 10 shrinks: the Hasselblad Lunar (a Sony NEX-7 ARW under its own Make) decodes and is verified, 236 models (#1275) | +| 2026-10-12 | minor | Gap 14: HLG AVIF export (`hdrTransfer`) and a tested SDR/HDR display decision | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 668dcf7fe..3534f49df 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -151,7 +151,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | | LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram | -| LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | | | +| LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | `apps/lightcraft/src/hdr_display.rs` (`choose`, `preferred_hdr_format`) | SDR/HDR decision (platform, surface format, OS headroom, `LIGHTCRAFT_HDR_DISPLAY=0`) is unit-tested and logged at start; always SDR today because eframe configures an 8-bit surface; no OS headroom query yet | ## D. Search & filter (FILT) @@ -380,7 +380,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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; marked on the HDR histogram (whose scale stays at 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, 16-bit Rec. 2020 PQ PNG (`cICP` + `cLLI`; `crates/codecs/src/encode.rs` `encode_png_pq`), 16-bit Rec. 2020 PQ JPEG XL, 32-bit float TIFF; no HLG 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 AVIF with PQ or HLG (`hdrTransfer`, BT.2100 / BT.2408, SDR white at signal 0.75), 16-bit Rec. 2020 PQ PNG (`cICP` + `cLLI`; `crates/codecs/src/encode.rs` `encode_png_pq`), 16-bit Rec. 2020 PQ JPEG XL, 32-bit float TIFF; no HEIC (no permissive HEVC encoder) | ## R. Video (VID) @@ -404,7 +404,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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/JPEG XL 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); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile | -| 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, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet | +| 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 or HLG AVIF, PQ PNG, PQ JPEG XL, 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, insetX, insetY, inset, rotation, 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` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | @@ -817,6 +817,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LR-IMP-CAMERA-COVERAGE: Nikon D90 NEFs drop their 42 filler columns (pink band on the right) and clip at one measured white level; status unchanged (🟡) | | 2026-10-13 | minor | LR-VER-HISTORY, LRC-DEV-HISTORY: History cursor (go to a step without adding one; Previous/Next History Step) | | 2026-10-12 | minor | LR-IMP-FORMATS: the Hasselblad Lunar (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data instead of opening as its preview; an uncompressed Leaf MOS imports as a preview-only raw instead of a greyscale image of its mosaic (#1275); status unchanged (🟡) | +| 2026-10-12 | minor | LR-HDR-EXPORT, LR-EXP-HDR: HLG AVIF export (`hdrTransfer`); LR-VIEW-HDR-DISPLAY: tested SDR/HDR display decision | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |