mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 02:59:17 -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user