LR-HDR-EXPORT: HDR AVIF (10-bit Rec. 2020 PQ)

`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 <noreply@anthropic.com>
This commit is contained in:
akvaithi
2026-10-09 22:40:37 -05:00
co-authored by Claude Opus 5.5
parent af06f2dbc1
commit 7828f93c32
12 changed files with 203 additions and 23 deletions
+30 -13
View File
@@ -601,8 +601,9 @@ pub struct ExportOptions {
/// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`].
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), 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<PreparedExport, String> {
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() }))
+18
View File
@@ -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));
}