LR-HDR-EXPORT: HDR PNG export as 16-bit Rec. 2020 PQ (cICP, cLLI)

With HDR output on, a photo edited in HDR exports as a 16-bit PNG with the PQ
curve in Rec. 2020 (SDR white at HDR_REFERENCE_WHITE_NITS), described by a
`cICP` chunk (PNG third edition: BT.2020 primaries, PQ, RGB, full range) and
a `cLLI` content light level chunk (the levels the HDR AVIF records). EXIF,
XMP and print resolution are kept; no ICC profile next to cICP. Photos without
an HDR edit, and HDR edits with HDR output off, export as before.

`codecs::encode_png_pq` sits next to `encode_avif_pq` and takes the same
input. Follow-up to #646, ported from #466.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
JordyUSA
2026-10-11 12:46:27 +07:00
co-authored by Claude Opus 5.5
parent 0ba118de92
commit cbc0e2d7db
9 changed files with 147 additions and 13 deletions
+14 -6
View File
@@ -602,8 +602,9 @@ pub struct ExportOptions {
pub bit_depth: Option<u8>,
/// HDR output for photos edited in HDR: JPEG as an ISO 21496-1 gain map JPEG (the SDR
/// rendition plus a gain map, so the file looks right everywhere; quality as set, `limit_kb`
/// not applied), AVIF as 10-bit Rec. 2020 PQ, 32-bit float TIFF with the highlights above SDR
/// white kept. Other formats, and photos without an HDR edit, are SDR.
/// not applied), AVIF as 10-bit Rec. 2020 PQ, PNG as 16-bit Rec. 2020 PQ (`cICP`), 32-bit float
/// TIFF with the highlights above SDR white kept. Other formats, and photos without an HDR
/// edit, are SDR.
pub hdr: bool,
}
@@ -995,6 +996,7 @@ impl ExportOptions {
pub fn effective_depth(&self) -> OutputDepth {
match (self.format, self.bit_depth) {
(ExportFormat::Jpeg | ExportFormat::Webp | ExportFormat::Original | ExportFormat::Dng, _) => OutputDepth::U8,
(ExportFormat::Png, _) if self.hdr => OutputDepth::F32Hdr,
(ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16,
(ExportFormat::Png, _) => OutputDepth::U8,
(ExportFormat::Tiff, Some(8)) => OutputDepth::U8,
@@ -1018,15 +1020,20 @@ impl ExportOptions {
}
/// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF
/// (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`].
/// or PNG (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`].
pub fn hdr_output(&self) -> bool {
self.hdr && matches!((self.format, self.bit_depth), (ExportFormat::Jpeg | ExportFormat::Avif, _) | (ExportFormat::Tiff, Some(32)))
self.hdr
&& matches!(
(self.format, self.bit_depth),
(ExportFormat::Jpeg | ExportFormat::Avif | ExportFormat::Png, _) | (ExportFormat::Tiff, Some(32))
)
}
/// The colour space the file is actually written in (AVIF: sRGB, or Rec. 2020 for HDR).
/// The colour space the file is actually written in (AVIF: sRGB, or Rec. 2020 for HDR; HDR
/// PNG: Rec. 2020).
pub fn effective_space(&self) -> OutputSpace {
match self.format {
ExportFormat::Avif if self.hdr => OutputSpace::Rec2020,
ExportFormat::Avif | ExportFormat::Png if self.hdr => OutputSpace::Rec2020,
ExportFormat::Avif => OutputSpace::Srgb,
_ => self.color_space,
}
@@ -1283,6 +1290,7 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>)
DeepSamples::F32(v) => EncodeImage::new(w, h, 3, Samples::F32(v)),
};
let r = match (o.format, &img.samples) {
(ExportFormat::Png, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => lightcraft_codecs::encode_png_pq(w, h, v, &meta),
(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 => {
+25
View File
@@ -440,3 +440,28 @@ fn hdr_avif_export_is_pq_rec2020() {
// a photo without an HDR edit exports as an ordinary (sRGB) AVIF
assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16));
}
#[test]
fn hdr_png_export_is_16_bit_pq_rec2020() {
let mut s = Session::with_demo();
let id = sunset(&mut s);
let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "png", "hdr": true}));
assert!(o.hdr_output());
assert_eq!(o.effective_space(), lightcraft_pipeline::OutputSpace::Rec2020);
// a photo without an HDR edit exports as an ordinary (sRGB, 8-bit) PNG
let sdr = export_photo(&mut s, id, &o, 1).unwrap();
assert!(!sdr.bytes.windows(4).any(|w| w == b"cICP"));
assert_eq!(sdr.bytes[24], 8, "8-bit");
s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap();
let hdr = export_photo(&mut s, id, &o, 1).unwrap();
let at = hdr.bytes.windows(4).position(|w| w == b"cICP").expect("cICP chunk");
assert_eq!(&hdr.bytes[at + 4..at + 8], &[9, 16, 0, 1], "BT.2020, PQ, RGB, full range");
assert_eq!(hdr.bytes[24], 16, "16-bit");
// the highlights above SDR white are there: MaxCLL beyond the 203 cd/m² of SDR white
let at = hdr.bytes.windows(4).position(|w| w == b"cLLI").expect("cLLI chunk");
let max_cll = u32::from_be_bytes(hdr.bytes[at + 4..at + 8].try_into().unwrap()) as f32 / 10_000.0;
assert!(max_cll > 250.0 && max_cll <= 4.0 * 203.0 + 1.0, "MaxCLL {max_cll}");
// HDR output off: the HDR edit exports its SDR rendition
let plain = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"longEdge": 160, "format": "png"})), 1).unwrap();
assert!(!plain.bytes.windows(4).any(|w| w == b"cICP"));
}