mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 20:48:33 -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,6 +30,8 @@ jxl-oxide = { workspace = true, optional = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
ravif = { workspace = true, optional = true }
|
||||
rav1e = { workspace = true, optional = true }
|
||||
avif-serialize = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["parallel", "jxl", "avif"]
|
||||
@@ -40,7 +42,7 @@ jxl = ["dep:jxl-oxide"]
|
||||
# PhotoCraft); `cargo xtask ci` clippies and tests it (`--features heif`), releases don't ship it yet.
|
||||
heif = ["dep:lightcraft-heif"]
|
||||
## AVIF encoding via ravif/rav1e (pure Rust, BSD). Native targets only; a no-op on wasm32.
|
||||
avif = ["dep:ravif"]
|
||||
avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"]
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
||||
@@ -52,7 +52,7 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
|
||||
| GIF / BMP | yes (first GIF frame) | — | via `image` |
|
||||
| PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported |
|
||||
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | — | enum colour → rendered straight to linear Rec.2020; ICC → our ICC path; orientation applied by the decoder (reported as 1) |
|
||||
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8-bit sRGB, EXIF; no ICC in the muxer |
|
||||
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_pq` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) |
|
||||
| HEIC/HEIF | **no** (sniff only) | — | see gaps |
|
||||
| Gain map JPEG (HDR) | gain map found and decoded (`gainmap::read_jpeg`): ISO 21496-1 metadata, else Adobe `hdrgm` XMP | yes (`gainmap::encode_jpeg`) | CIPA DC-007 MPF index (gain map typed `0x050000`), ISO 21496-1 APP2 on both images, `hdrgm` + Container XMP for Android/Chrome, Apple `HDRGainMap`/`HDRToneMap` XMP; gain maps are never used as thumbnails |
|
||||
|
||||
|
||||
@@ -363,6 +363,141 @@ pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta)
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute luminance of SDR white in an HDR file (ITU-R BT.2408 reference white), cd/m².
|
||||
pub const HDR_REFERENCE_WHITE_NITS: f32 = 203.0;
|
||||
|
||||
/// SMPTE ST 2084 (PQ) inverse EOTF: luminance in cd/m² → signal 0..1.
|
||||
pub fn pq_encode(nits: f32) -> f32 {
|
||||
const M1: f32 = 2610.0 / 16384.0;
|
||||
const M2: f32 = 2523.0 / 4096.0 * 128.0;
|
||||
const C1: f32 = 3424.0 / 4096.0;
|
||||
const C2: f32 = 2413.0 / 4096.0 * 32.0;
|
||||
const C3: f32 = 2392.0 / 4096.0 * 32.0;
|
||||
let y = (nits / 10_000.0).clamp(0.0, 1.0).powf(M1);
|
||||
((C1 + C2 * y) / (1.0 + C3 * y)).powf(M2)
|
||||
}
|
||||
|
||||
/// SMPTE ST 2084 (PQ) EOTF: signal 0..1 → luminance in cd/m².
|
||||
pub fn pq_decode(e: f32) -> f32 {
|
||||
const M1: f32 = 2610.0 / 16384.0;
|
||||
const M2: f32 = 2523.0 / 4096.0 * 128.0;
|
||||
const C1: f32 = 3424.0 / 4096.0;
|
||||
const C2: f32 = 2413.0 / 4096.0 * 32.0;
|
||||
const C3: f32 = 2392.0 / 4096.0 * 32.0;
|
||||
let p = e.clamp(0.0, 1.0).powf(1.0 / M2);
|
||||
((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1) * 10_000.0
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// `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>> {
|
||||
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 || rgb.len() < n * 3 {
|
||||
return Err(Error::Encode("HDR AVIF: sample buffer too short".into()));
|
||||
}
|
||||
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
|
||||
{
|
||||
use rav1e::prelude::*;
|
||||
const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; // BT.2020 NCL
|
||||
let mut max_nits = 0f32;
|
||||
let mut sum_nits = 0f64;
|
||||
let planes: Vec<[u16; 3]> = rgb
|
||||
.as_chunks::<3>()
|
||||
.0
|
||||
.iter()
|
||||
.take(n)
|
||||
.map(|c| {
|
||||
let nits = c.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * HDR_REFERENCE_WHITE_NITS);
|
||||
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 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;
|
||||
[y, cb, cr].map(|v| (v * 1023.0).round().clamp(0.0, 1023.0) as u16)
|
||||
})
|
||||
.collect();
|
||||
let q = quality.clamp(1, 100) as f32 / 100.0;
|
||||
// ravif's quality → quantizer curve, so qualities match the SDR AVIF path
|
||||
let x = if q >= 0.82 {
|
||||
(1.0 - q) * 2.6
|
||||
} else if q > 0.25 {
|
||||
1.0 - 0.125 - q * 0.5
|
||||
} else {
|
||||
1.0 - q
|
||||
};
|
||||
let quantizer = (x * 255.0).round() as usize;
|
||||
let mut ec = EncoderConfig::with_speed_preset(speed.clamp(1, 10));
|
||||
ec.width = w;
|
||||
ec.height = h;
|
||||
ec.time_base = Rational::new(1, 1);
|
||||
ec.bit_depth = 10;
|
||||
ec.chroma_sampling = ChromaSampling::Cs444;
|
||||
ec.pixel_range = PixelRange::Full;
|
||||
ec.color_description = Some(ColorDescription {
|
||||
color_primaries: ColorPrimaries::BT2020,
|
||||
transfer_characteristics: TransferCharacteristics::SMPTE2084,
|
||||
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.still_picture = true;
|
||||
ec.quantizer = quantizer;
|
||||
ec.min_quantizer = quantizer.min(255) as u8;
|
||||
ec.speed_settings.multiref = false;
|
||||
ec.speed_settings.rdo_lookahead_frames = 1;
|
||||
ec.speed_settings.scene_detection_mode = SceneDetectionSpeed::None;
|
||||
let e = |e: &dyn std::fmt::Display| Error::Encode(format!("AV1: {e}"));
|
||||
let mut ctx: Context<u16> = Config::new().with_encoder_config(ec).new_context().map_err(|x| e(&x))?;
|
||||
let mut frame = ctx.new_frame();
|
||||
{
|
||||
let mut it = planes.iter();
|
||||
let mut fp = frame.planes.iter_mut();
|
||||
let (Some(py), Some(pu), Some(pv)) = (fp.next(), fp.next(), fp.next()) else {
|
||||
return Err(Error::Encode("AV1: frame has no planes".into()));
|
||||
};
|
||||
let (mut sy, mut su, mut sv) = (py.mut_slice(Default::default()), pu.mut_slice(Default::default()), pv.mut_slice(Default::default()));
|
||||
for ((ry, ru), rv) in sy.rows_iter_mut().zip(su.rows_iter_mut()).zip(sv.rows_iter_mut()).take(h) {
|
||||
for ((y, u), v) in ry.iter_mut().zip(ru.iter_mut()).zip(rv.iter_mut()).take(w) {
|
||||
let Some(p) = it.next() else { break };
|
||||
(*y, *u, *v) = (p[0], p[1], p[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.send_frame(frame).map_err(|x| e(&x))?;
|
||||
ctx.flush();
|
||||
let mut av1 = Vec::new();
|
||||
loop {
|
||||
match ctx.receive_packet() {
|
||||
Ok(mut p) if p.frame_type == FrameType::KEY => av1.append(&mut p.data),
|
||||
Ok(_) => {}
|
||||
Err(EncoderStatus::Encoded | EncoderStatus::LimitReached) => break,
|
||||
Err(x) => return Err(e(&x)),
|
||||
}
|
||||
}
|
||||
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_color_primaries(c::ColorPrimaries::Bt2020)
|
||||
.set_full_color_range(true)
|
||||
.set_content_light_level(cll.0, cll.1);
|
||||
if let Some(exif) = meta.exif {
|
||||
a.set_exif(exif.to_vec());
|
||||
}
|
||||
Ok(a.to_vec(&av1, None, width, height, 10))
|
||||
}
|
||||
#[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))]
|
||||
{
|
||||
let _ = (quality, speed, meta);
|
||||
Err(Error::Encode("AVIF encoding is not available in this build".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 16-bit RGB(A) → 10-bit BT.601 full-range YCbCr planes (the matrix `ravif` uses for 8-bit input).
|
||||
#[cfg(all(feature = "avif", not(target_arch = "wasm32")))]
|
||||
fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result<Vec<u8>> {
|
||||
|
||||
@@ -35,7 +35,8 @@ mod tiff_codec;
|
||||
mod webp;
|
||||
|
||||
pub use encode::{
|
||||
ChromaSubsampling, EncodeImage, EncodeMeta, Samples, TiffCompression, encode_avif, encode_jpeg, encode_png, encode_tiff, encode_webp_lossless,
|
||||
ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg,
|
||||
encode_png, encode_tiff, encode_webp_lossless,
|
||||
};
|
||||
pub use sniff::{Format, sniff};
|
||||
pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
|
||||
|
||||
+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));
|
||||
}
|
||||
|
||||
@@ -532,14 +532,16 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd);
|
||||
opts.bit_depth = Some(bd);
|
||||
}
|
||||
if matches!(opts.format, F::Jpeg | F::Tiff) {
|
||||
let tip = "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays); TIFF needs 32-bit float";
|
||||
if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif) {
|
||||
let tip = "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit Rec. 2020 PQ; TIFF needs 32-bit float";
|
||||
ui.checkbox(&mut opts.hdr, "HDR output").on_hover_text(tip);
|
||||
if opts.hdr && !opts.hdr_output() {
|
||||
ui.label(egui::RichText::new("HDR TIFF needs 32-bit float.").color(t.text_dim));
|
||||
}
|
||||
}
|
||||
if !rendered {
|
||||
} else if opts.format == F::Avif && opts.hdr {
|
||||
ui.label(egui::RichText::new("Color space: Rec. 2020 PQ (HDR AVIF)").color(t.text_dim));
|
||||
} else if opts.format == F::Avif {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim));
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user