LR-HDR-EXPORT: HLG HDR AVIF export (hdrTransfer) and a tested SDR/HDR display decision

AVIF HDR export takes hdrTransfer = pq | hlg (export dialog: HDR curve;
MCP export schema lists it). HLG writes CICP transfer 18 and no clli box.
The window logs whether it shows HDR; today always SDR (eframe configures
an 8-bit surface), with the platform / surface format / headroom / opt-out
rules unit-tested. Also satisfies clippy 1.99's chunks_exact_to_as_chunks
in spots.rs.

Part of #868.

Sources: ITU-R BT.2100 (HLG OETF, inverse OOTF with system gamma 1.2),
ITU-R BT.2408 (203 cd/m2 reference white at 75 % HLG); own design for
the display decision.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Andrew FrahnandClaude Opus 5.5 committed 2026-10-12 03:02:02 +11:00
1 parent 76b5377868
commit 0a6f8d3db8
12 files changed
+331 -22

No files matched your search

+174
View File
@@ -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<HdrEncoding> {
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<TextureFormat> {
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<TextureFormat>, platform: Platform, display_headroom: Option<f32>, 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<TextureFormat>) -> 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);
}
}
+3
View File
@@ -27,6 +27,7 @@ mod appearance;
mod control_server;
mod dialog_filter;
mod editors;
mod hdr_display;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
@@ -833,6 +834,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;
+1 -1
View File
@@ -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 |
+85 -9
View File
@@ -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<Vec<u8>> {
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<Vec<u8>> {
pub fn encode_avif_hdr(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta, transfer: HdrTransfer) -> Result<Vec<u8>> {
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<Vec<
.map_err(|e| Error::Encode(e.to_string()))?;
Ok(r.avif_file)
}
#[cfg(test)]
mod hlg_tests {
use super::*;
#[test]
fn hlg_reference_white_and_peak() {
// BT.2408: SDR (HDR reference) white sits at 75 % HLG signal
for v in hlg_signal([1.0; 3]) {
assert!((v - 0.75).abs() < 0.005, "{v}");
}
// the 1000 cd/m² peak (≈ 4.9× SDR white) reaches full signal; above it clips
let peak = HLG_REFERENCE_PEAK_NITS / HDR_REFERENCE_WHITE_NITS;
assert!(hlg_signal([peak; 3]).iter().all(|v| (v - 1.0).abs() < 1e-3));
assert_eq!(hlg_signal([100.0; 3]), [1.0; 3]);
// black, negatives and non-finite values are 0, never NaN
assert_eq!(hlg_signal([0.0, -1.0, f32::NAN]), [0.0; 3]);
assert!(hlg_signal([f32::INFINITY, 0.0, 0.0]).iter().all(|v| v.is_finite()));
// monotonic in brightness
let mut prev = 0.0;
for i in 1..50 {
let v = hlg_signal([i as f32 * 0.1; 3])[1];
assert!(v >= 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());
}
}
+2 -2
View File
@@ -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};
+33 -1
View File
@@ -623,6 +623,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<Self> {
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 {
@@ -681,6 +707,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 {
@@ -710,6 +738,7 @@ impl Default for ExportOptions {
jxl_progressive: false,
add_to_photos: false,
photos_album: String::new(),
hdr_transfer: HdrCurve::Pq,
}
}
}
@@ -751,6 +780,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
@@ -923,6 +953,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"])?,
@@ -1061,6 +1092,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),
}
}
@@ -1450,7 +1482,7 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)
(ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta),
(ExportFormat::Psd, _) => encode::encode_psd(&e, &meta),
(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 => {
+15
View File
@@ -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]
+2
View File
@@ -262,6 +262,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"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",
+2 -2
View File
@@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh
let flat: Vec<f32> = tgt.iter().flatten().copied().collect();
let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default())
.unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole));
for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) {
*d = [v[0], v[1], v[2]];
for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) {
*d = *v;
}
}
+6 -1
View File
@@ -710,10 +710,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).");
+3 -2
View File
@@ -1,6 +1,6 @@
# Gaps: where LightCraft falls short of Lightroom
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 14: HLG AVIF export done, HDR display decision tested); previously minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
@@ -28,7 +28,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) |
@@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
| Date | Change | Summary |
|---|---|---|
| 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 |
+5 -4
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-HDR-EXPORT / LR-EXP-HDR: HLG AVIF; LR-VIEW-HDR-DISPLAY decision logic); previously minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -139,7 +139,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)
@@ -366,7 +366,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)
@@ -390,7 +390,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` |
@@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| Date | Change | Summary |
|---|---|---|
| 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 |