mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 18:47:42 -06:00
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:
co-authored by
Claude Opus 5.5
parent
af06f2dbc1
commit
7828f93c32
+30
-13
@@ -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() }))
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user