mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 20:17:18 -06:00
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
76b5377868
commit
0a6f8d3db8
@@ -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 => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user