Skip to content

Commit e81b82c

Browse files
committed
HDR
Known limitations of this prototype: 1. Resize still runs in gamma space (gift library), not linear-light PQ. For HDR you'd ideally resize in linear, then re-encode to PQ. Visible difference is usually subtle but worth a follow-up. 2. The non-gain-map HDR path (true PQ source like netflix-reencoded.avif) is unchanged — already worked. 3. Quality of the bake depends on gainMap->alternateHdrHeadroom; if a Lightroom export sets this to 0 we'd silently fall to SDR-equivalent. Worth a sanity check if you hit such a file. Worth opening in Safari on an HDR-capable Mac and comparing against the original straws.avif to confirm the highlights pop the way you expect.
1 parent db39d95 commit e81b82c

11 files changed

Lines changed: 165 additions & 12 deletions

File tree

hugolib/site.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ func NewHugoSites(cfg deps.DepsCfg) (*HugoSites, error) {
209209
if n := config.GetNumWorkerMultiplier(); n > 1 {
210210
poolSizeKatex = min(n, 8)
211211
poolSizeWebP = max(2, n/2)
212-
poolSizeAvif = max(2, n/2)
212+
poolSizeAvif = max(3, n/2)
213213
}
214214

215215
var logger loggers.Logger

internal/warpc/avif.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ func (d *AvifCodec) Decode(r io.Reader) (image.Image, error) {
145145
colorPrimaries: out.Data.Params.ColorPrimaries,
146146
transferCharacteristics: out.Data.Params.TransferCharacteristics,
147147
matrixCoefficients: out.Data.Params.MatrixCoefficients,
148+
maxCLL: out.Data.Params.MaxCLL,
149+
maxPALL: out.Data.Params.MaxPALL,
148150
}
149151

150152
frameSize := stride * h
@@ -188,6 +190,8 @@ func (d *AvifCodec) Decode(r io.Reader) (image.Image, error) {
188190
colorPrimaries: out.Data.Params.ColorPrimaries,
189191
transferCharacteristics: out.Data.Params.TransferCharacteristics,
190192
matrixCoefficients: out.Data.Params.MatrixCoefficients,
193+
maxCLL: out.Data.Params.MaxCLL,
194+
maxPALL: out.Data.Params.MaxPALL,
191195
}
192196
return img, nil
193197
}
@@ -224,6 +228,8 @@ func (d *AvifCodec) Encode(w io.Writer, src image.Image, options map[string]any)
224228
params["colorPrimaries"] = img.GetColorPrimaries()
225229
params["transferCharacteristics"] = img.GetTransferCharacteristics()
226230
params["matrixCoefficients"] = img.GetMatrixCoefficients()
231+
params["maxCLL"] = img.GetMaxCLL()
232+
params["maxPALL"] = img.GetMaxPALL()
227233

228234
// Handle the first frame based on its type.
229235
firstFrame := frames[0]

internal/warpc/avif_color_test.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ func TestAvifColorPropertyPreservation(t *testing.T) {
1515
t.Skip("Skipping in short mode")
1616
}
1717

18-
// Test that dock.avif (BT.709/sRGB) maintains its color properties when re-encoded
18+
// dock-75-hdr.avif is Lightroom-style SDR+gain-map HDR; the decoder bakes the
19+
// gain map into a single BT.2020/PQ HDR AVIF. netflix-reencoded.avif is already
20+
// a true HDR PQ AVIF and round-trips with its CICP intact.
1921
files := `
2022
-- hugo.toml --
2123
-- assets/dock.avif --
@@ -59,10 +61,10 @@ NetflixReencoded: {{ $netflixReencoded.RelPermalink }}
5961
publicDir := filepath.Join(b.Cfg.WorkingDir, "public")
6062
t.Logf("Looking in: %s", publicDir)
6163

62-
// dock.avif should remain BT.709/sRGB
64+
// dock-75-hdr.avif has a gain map, so the output should be BT.2020/PQ.
6365
dockMatches, _ := filepath.Glob(filepath.Join(publicDir, "dock_hu*.avif"))
6466
if len(dockMatches) > 0 {
65-
checkColorProps(t, "dock", dockMatches[0], "BT.709", "sRGB")
67+
checkColorProps(t, "dock", dockMatches[0], "BT.2020", "PQ")
6668
} else {
6769
t.Error("No dock AVIF file found in output")
6870
}

internal/warpc/genavif/avif.c

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ typedef struct
3939
int transferCharacteristics;
4040
int matrixCoefficients;
4141

42+
// HDR CLLI box (max content/picture-average light level, in cd/m^2).
43+
int maxCLL;
44+
int maxPALL;
45+
4246
} InputParams;
4347

4448
typedef struct
@@ -123,6 +127,8 @@ InputMessage parse_input_message(const char *line)
123127
msg.data.params.colorPrimaries = (int)json_object_get_number(params_object, "colorPrimaries");
124128
msg.data.params.transferCharacteristics = (int)json_object_get_number(params_object, "transferCharacteristics");
125129
msg.data.params.matrixCoefficients = (int)json_object_get_number(params_object, "matrixCoefficients");
130+
msg.data.params.maxCLL = (int)json_object_get_number(params_object, "maxCLL");
131+
msg.data.params.maxPALL = (int)json_object_get_number(params_object, "maxPALL");
126132
JSON_Array *durations_array = json_object_get_array(params_object, "frameDurations");
127133
if (durations_array != NULL)
128134
{
@@ -209,6 +215,8 @@ void write_output_message(const OutputMessage *msg)
209215
json_object_set_number(params_object, "colorPrimaries", msg->data.params.colorPrimaries);
210216
json_object_set_number(params_object, "transferCharacteristics", msg->data.params.transferCharacteristics);
211217
json_object_set_number(params_object, "matrixCoefficients", msg->data.params.matrixCoefficients);
218+
json_object_set_number(params_object, "maxCLL", msg->data.params.maxCLL);
219+
json_object_set_number(params_object, "maxPALL", msg->data.params.maxPALL);
212220
if (msg->data.params.frameDurations != NULL)
213221
{
214222
JSON_Value *durations_value = json_value_init_array();
@@ -292,6 +300,8 @@ void handle_commands(FILE *stream)
292300
decoder->ignoreExif = AVIF_TRUE;
293301
decoder->ignoreXMP = AVIF_TRUE;
294302
decoder->maxThreads = 1;
303+
// Request gain map pixels when present (e.g. Lightroom HDR exports).
304+
decoder->imageContentToDecode = AVIF_IMAGE_CONTENT_ALL;
295305

296306
avifResult result = avifDecoderSetIOMemory(decoder, blob_data, blob_size);
297307
if (result != AVIF_RESULT_OK)
@@ -311,6 +321,84 @@ void handle_commands(FILE *stream)
311321
goto cleanup;
312322
}
313323

324+
// If a gain map is present (e.g. Adobe-style SDR+gainmap HDR from Lightroom),
325+
// bake it into a single true-HDR image in BT.2020/PQ at 10-bit.
326+
// The downstream pipeline then sees a normal HDR AVIF; SDR clients/displays
327+
// tone-map automatically.
328+
avifBool hasGainMap = (decoder->image->gainMap != NULL && decoder->image->gainMap->image != NULL);
329+
if (hasGainMap)
330+
{
331+
// Need a full frame to apply the gain map.
332+
result = avifDecoderNextImage(decoder);
333+
if (result != AVIF_RESULT_OK)
334+
{
335+
snprintf(output.header.err, sizeof(output.header.err), "Failed to decode AVIF for gain map: %s", avifResultToString(result));
336+
avifDecoderDestroy(decoder);
337+
write_output_message(&output);
338+
goto cleanup;
339+
}
340+
341+
avifRGBImage outRGB;
342+
memset(&outRGB, 0, sizeof(outRGB));
343+
avifRGBImageSetDefaults(&outRGB, decoder->image);
344+
outRGB.format = AVIF_RGB_FORMAT_RGBA;
345+
outRGB.depth = 16;
346+
if (avifRGBImageAllocatePixels(&outRGB) != AVIF_RESULT_OK)
347+
{
348+
snprintf(output.header.err, sizeof(output.header.err), "Failed to allocate RGB pixels for gain map apply");
349+
avifDecoderDestroy(decoder);
350+
write_output_message(&output);
351+
goto cleanup;
352+
}
353+
354+
// Target the alternate (full-HDR) endpoint: alternateHdrHeadroom = log2(HDR/SDR).
355+
float hdrHeadroom = 0.0f;
356+
const avifUnsignedFraction *h = &decoder->image->gainMap->alternateHdrHeadroom;
357+
if (h->d != 0)
358+
{
359+
hdrHeadroom = (float)h->n / (float)h->d;
360+
}
361+
362+
avifContentLightLevelInformationBox outCLLI = {0};
363+
avifResult applyResult = avifImageApplyGainMap(
364+
decoder->image,
365+
decoder->image->gainMap,
366+
hdrHeadroom,
367+
AVIF_COLOR_PRIMARIES_BT2020,
368+
AVIF_TRANSFER_CHARACTERISTICS_PQ,
369+
&outRGB,
370+
&outCLLI,
371+
NULL);
372+
if (applyResult != AVIF_RESULT_OK)
373+
{
374+
snprintf(output.header.err, sizeof(output.header.err), "Failed to apply gain map: %s", avifResultToString(applyResult));
375+
avifRGBImageFreePixels(&outRGB);
376+
avifDecoderDestroy(decoder);
377+
write_output_message(&output);
378+
goto cleanup;
379+
}
380+
381+
output.data.params.width = outRGB.width;
382+
output.data.params.height = outRGB.height;
383+
// Tell the Go side we're 10-bit HDR so it wraps in NRGBA64 and re-encodes as HDR.
384+
output.data.params.depth = 10;
385+
output.data.params.stride = outRGB.rowBytes;
386+
output.data.params.frameCount = 1;
387+
output.data.params.colorPrimaries = AVIF_COLOR_PRIMARIES_BT2020;
388+
output.data.params.transferCharacteristics = AVIF_TRANSFER_CHARACTERISTICS_PQ;
389+
output.data.params.matrixCoefficients = AVIF_MATRIX_COEFFICIENTS_BT2020_NCL;
390+
output.data.params.maxCLL = outCLLI.maxCLL;
391+
output.data.params.maxPALL = outCLLI.maxPALL;
392+
393+
size_t blob_out_size = (size_t)outRGB.rowBytes * outRGB.height;
394+
write_output_message(&output);
395+
write_blob(output.header.id, outRGB.pixels, blob_out_size);
396+
397+
avifRGBImageFreePixels(&outRGB);
398+
avifDecoderDestroy(decoder);
399+
goto cleanup;
400+
}
401+
314402
// YUV420 (4:2:0) and YUV422 (4:2:2) chroma subsampling cause issues in WASM.
315403
// Only YUV444 (4:4:4) is currently supported.
316404
if (decoder->image->yuvFormat == AVIF_PIXEL_FORMAT_YUV420 ||
@@ -511,13 +599,19 @@ void handle_commands(FILE *stream)
511599
}
512600
image->yuvRange = AVIF_RANGE_FULL;
513601

602+
// CLLI carries HDR peak/avg light levels from a baked gain map.
603+
if (input.data.params.maxCLL > 0 || input.data.params.maxPALL > 0) {
604+
image->clli.maxCLL = (uint16_t)input.data.params.maxCLL;
605+
image->clli.maxPALL = (uint16_t)input.data.params.maxPALL;
606+
}
607+
514608
avifRGBImage rgb;
515609
avifRGBImageSetDefaults(&rgb, image);
516610
rgb.format = AVIF_RGB_FORMAT_RGBA;
517611
rgb.depth = rgb_depth;
518612
rgb.pixels = (uint8_t *)blob_data;
519613
rgb.rowBytes = stride;
520-
614+
521615
avifResult result = avifImageRGBToYUV(image, &rgb);
522616
if (result != AVIF_RESULT_OK) {
523617
snprintf(output.header.err, sizeof(output.header.err), "encodeNRGBA: Failed to convert to YUV: %s", avifResultToString(result));
@@ -625,6 +719,11 @@ void handle_commands(FILE *stream)
625719
}
626720
image->yuvRange = AVIF_RANGE_FULL;
627721

722+
if (input.data.params.maxCLL > 0 || input.data.params.maxPALL > 0) {
723+
image->clli.maxCLL = (uint16_t)input.data.params.maxCLL;
724+
image->clli.maxPALL = (uint16_t)input.data.params.maxPALL;
725+
}
726+
628727
avifResult alloc_result = avifImageAllocatePlanes(image, AVIF_PLANES_YUV);
629728
if (alloc_result != AVIF_RESULT_OK) {
630729
snprintf(output.header.err, sizeof(output.header.err), "encodeGray: Failed to allocate planes: %s", avifResultToString(alloc_result));

internal/warpc/wasm/avif.wasm

11.1 KB
Binary file not shown.

internal/warpc/webp.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ type CommonImageProcessingParams struct {
4848
ColorPrimaries int `json:"colorPrimaries,omitempty"`
4949
TransferCharacteristics int `json:"transferCharacteristics,omitempty"`
5050
MatrixCoefficients int `json:"matrixCoefficients,omitempty"`
51+
52+
// HDR CLLI box (cd/m^2). Populated by the AVIF decoder when a gain map has
53+
// been baked into a PQ HDR image, and re-applied by the encoder.
54+
MaxCLL int `json:"maxCLL,omitempty"`
55+
MaxPALL int `json:"maxPALL,omitempty"`
5156
}
5257

5358
/*
@@ -374,6 +379,10 @@ type AnimatedImage struct {
374379
colorPrimaries int
375380
transferCharacteristics int
376381
matrixCoefficients int
382+
383+
// HDR CLLI (cd/m^2). Set when an AVIF gain map has been baked into PQ HDR.
384+
maxCLL int
385+
maxPALL int
377386
}
378387

379388
func (w *AnimatedImage) GetLoopCount() int {
@@ -408,6 +417,14 @@ func (w *AnimatedImage) GetMatrixCoefficients() int {
408417
return w.matrixCoefficients
409418
}
410419

420+
func (w *AnimatedImage) GetMaxCLL() int {
421+
return w.maxCLL
422+
}
423+
424+
func (w *AnimatedImage) GetMaxPALL() int {
425+
return w.maxPALL
426+
}
427+
411428
func (w *AnimatedImage) SetFrames(frames []image.Image) {
412429
if len(frames) == 0 {
413430
panic("frames cannot be empty")

resources/images/codec.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ type Codec struct {
6767

6868
// TODO1 remove this.
6969
func stopClock(what string, start time.Time) {
70-
fmt.Printf("%q: %s\n", what, time.Since(start))
70+
fmt.Printf("%-10s %s\n", what, time.Since(start))
7171
}
7272

7373
func newCodec(webp, avif EncodeDecoder, debugl logg.LevelLogger) *Codec {
@@ -76,7 +76,7 @@ func newCodec(webp, avif EncodeDecoder, debugl logg.LevelLogger) *Codec {
7676

7777
func (d *Codec) EncodeTo(conf ImageConfig, w io.Writer, img image.Image) error {
7878
start := time.Now()
79-
defer stopClock("Encode", start)
79+
defer stopClock(fmt.Sprintf("Encode %s", conf.TargetFormat.String()), start)
8080
d.debugl.Log(
8181
// This construct looks odd, but the func will only be called if debug is enabled.
8282
logg.StringFunc(
@@ -172,7 +172,7 @@ func (d *Codec) EncodeTo(conf ImageConfig, w io.Writer, img image.Image) error {
172172

173173
func (d *Codec) DecodeFormat(f Format, r io.Reader) (image.Image, error) {
174174
start := time.Now()
175-
defer stopClock("Decode", start)
175+
defer stopClock(fmt.Sprintf("Decode %s", f.String()), start)
176176
d.debugl.Log(
177177
logg.StringFunc(
178178
func() string {
@@ -231,7 +231,6 @@ func (d *Codec) DecodeFormat(f Format, r io.Reader) (image.Image, error) {
231231

232232
func (d *Codec) Decode(r io.Reader) (image.Image, error) {
233233
start := time.Now()
234-
defer stopClock("Decode", start)
235234
d.debugl.Log(
236235
logg.StringFunc(
237236
func() string {
@@ -245,6 +244,7 @@ func (d *Codec) Decode(r io.Reader) (image.Image, error) {
245244
return nil, err
246245
}
247246
if format != 0 {
247+
defer stopClock("Decode "+format.String(), start)
248248
return d.DecodeFormat(format, rr)
249249
}
250250

resources/images/images_golden_integration_test.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -356,8 +356,6 @@ func TestImagesGoldenProcessAvif(t *testing.T) {
356356
// Will be used as the base folder for generated images.
357357
name := "process/avif"
358358

359-
// TODO(bep) investigate why the Netflix originals crashes.
360-
361359
files := `
362360
-- hugo.toml --
363361
-- assets/dock.avif --
@@ -383,7 +381,38 @@ Home.
383381
opts.T = t
384382
opts.Name = name
385383
opts.Files = files
386-
opts.WriteFiles = true
384+
opts.WriteFiles = true // TODO1
385+
386+
imagetesting.RunGolden(opts)
387+
}
388+
389+
func TestImagesGoldenProcessAviStraws(t *testing.T) {
390+
t.Parallel()
391+
392+
if imagetesting.SkipGoldenTests {
393+
t.Skip("Skip golden test on this architecture")
394+
}
395+
396+
// Will be used as the base folder for generated images.
397+
name := "process/avifstraws"
398+
399+
files := `
400+
-- hugo.toml --
401+
-- assets/straws.avif --
402+
sourcefilename: ../testdata/bep/straws.avif
403+
-- layouts/home.html --
404+
Home.
405+
{{ $straws := resources.Get "straws.avif" }}
406+
{{ template "process" (dict "spec" "resize 900x" "img" $straws) }}
407+
408+
409+
` + goldenProcess
410+
411+
opts := imagetesting.DefaultGoldenOpts
412+
opts.T = t
413+
opts.Name = name
414+
opts.Files = files
415+
opts.WriteFiles = true // TODO1
387416

388417
imagetesting.RunGolden(opts)
389418
}
-14.4 KB
Loading
-17.2 KB
Binary file not shown.

0 commit comments

Comments
 (0)