From 15dba5052eac3a0b7323329baa3a0dbe4f5973c3 Mon Sep 17 00:00:00 2001 From: Mayk Thewessen Date: Fri, 19 Jun 2026 18:20:24 +0200 Subject: [PATCH 1/2] HEIF export: write HDR10 content light level (clli) for PQ darktable's HEIF exporter already tags PQ/HLG Rec.2020 (and P3) output with the correct nclx colour information, but never wrote the HDR10 content-light-level box, which players and displays use to tone-map (and which platforms such as iOS/Instagram expect on HDR stills). For PQ (SMPTE ST 2084) output the float samples are absolute-luminance encoded, so derive the values from the pixels via the PQ EOTF: - MaxCLL = brightest single sample (max RGB component), in nits, - MaxFALL = mean per-sample peak light level, in nits. Written via heif_image_set_content_light_level(). HLG is relative, so skipped. This mirrors the equivalent AVIF change and rounds out HDR10 HEIF output. Co-Authored-By: Claude Opus 4.8 --- src/imageio/format/heif.c | 58 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/imageio/format/heif.c b/src/imageio/format/heif.c index d8b038f382e6..d5498a2d74ee 100644 --- a/src/imageio/format/heif.c +++ b/src/imageio/format/heif.c @@ -26,6 +26,7 @@ #include #include +#include #include #include @@ -117,6 +118,25 @@ void cleanup(dt_imageio_module_format_t *self) { } +/* + * SMPTE ST 2084 (PQ) EOTF: map a normalized PQ-encoded value in [0, 1] to + * absolute luminance in cd/m^2 (nits), peak white = 10000 nits. + */ +static float _pq_to_nits(const float e) +{ + const float m1 = 0.1593017578125f; /* 2610 / 16384 */ + const float m2 = 78.84375f; /* 2523 / 4096 * 128 */ + const float c1 = 0.8359375f; /* 3424 / 4096 */ + const float c2 = 18.8515625f; /* 2413 / 4096 * 32 */ + const float c3 = 18.6875f; /* 2392 / 4096 * 32 */ + + const float ep = powf(CLAMP(e, 0.0f, 1.0f), 1.0f / m2); + const float num = fmaxf(ep - c1, 0.0f); + const float den = c2 - c3 * ep; + if(den <= 0.0f) return 10000.0f; + return 10000.0f * powf(num / den, 1.0f / m1); +} + int write_image(dt_imageio_module_data_t *data, const char *filename, const void *in_tmp, // ptr to input image buf @@ -324,6 +344,44 @@ int write_image(dt_imageio_module_data_t *data, } } + /* + * HDR10 content light level (clli) metadata. + * + * For PQ (SMPTE ST 2084) output the float samples are absolute-luminance + * encoded, so we can derive the real content light levels from the pixels: + * - MaxCLL = the brightest single sample (max RGB component) in nits, + * - MaxFALL = the mean over all samples of that per-sample peak. + * Players and HDR displays use these to tone-map appropriately. We only write + * them for PQ; HLG is relative and has no fixed nit scale. + */ + if(!need_to_embed_icc + && nclx_profile->transfer_characteristics == heif_transfer_characteristic_ITU_R_BT_2100_0_PQ) + { + const size_t npixels = width * height; + float max_cll = 0.0f; + double sum_fall = 0.0; + + DT_OMP_FOR_SIMD(reduction(max : max_cll) reduction(+ : sum_fall)) + for(size_t k = 0; k < npixels; k++) + { + const float *const px = &in_data[4 * k]; + const float e_max = fmaxf(fmaxf(px[0], px[1]), px[2]); + const float nits = _pq_to_nits(e_max); + max_cll = fmaxf(max_cll, nits); + sum_fall += nits; + } + const float max_fall = npixels ? (float)(sum_fall / (double)npixels) : 0.0f; + + const struct heif_content_light_level cll = { + .max_content_light_level = (uint16_t)CLAMP(roundf(max_cll), 0.0f, 65535.0f), + .max_pic_average_light_level = (uint16_t)CLAMP(roundf(max_fall), 0.0f, 65535.0f), + }; + heif_image_set_content_light_level(image, &cll); + + dt_print(DT_DEBUG_IMAGEIO, "[heif HDR10 clli: MaxCLL=%u nits, MaxFALL=%u nits]", + cll.max_content_light_level, cll.max_pic_average_light_level); + } + struct heif_context* context = heif_context_alloc(); struct heif_encoder* encoder; From b468a76df49b02fef7d7be20aa38576a0e6a5c5c Mon Sep 17 00:00:00 2001 From: Mayk Thewessen Date: Sun, 21 Jun 2026 23:21:18 +0200 Subject: [PATCH 2/2] HEIF HDR10 clli: ignore NaN/Inf samples in MaxFALL Same NaN-safety fix as AVIF: a non-finite sample poisoned the MaxFALL sum and the final clli cast. Treat non-finite per-pixel nits as 0. Co-Authored-By: Claude Opus 4.8 --- src/imageio/format/heif.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/imageio/format/heif.c b/src/imageio/format/heif.c index d5498a2d74ee..375dc6bff48b 100644 --- a/src/imageio/format/heif.c +++ b/src/imageio/format/heif.c @@ -366,7 +366,10 @@ int write_image(dt_imageio_module_data_t *data, { const float *const px = &in_data[4 * k]; const float e_max = fmaxf(fmaxf(px[0], px[1]), px[2]); - const float nits = _pq_to_nits(e_max); + const float nits_raw = _pq_to_nits(e_max); + // ignore NaN/Inf samples: they would poison the MaxFALL sum and make the + // final 16-bit clli cast undefined. + const float nits = isfinite(nits_raw) ? nits_raw : 0.0f; max_cll = fmaxf(max_cll, nits); sum_fall += nits; }