diff --git a/CHANGELOG.md b/CHANGELOG.md index fe4099b395..79b3dbe45c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ The changes are relative to the previous release, unless the baseline is specifi * Update LocalAvm.cmake: research-v15.0.0-rc1 * Update svt.cmd/svt.sh/LocalSvt.cmake: v4.1.0 * Fix decoding layered image with multiple scaled alpha layers +* Fix NaN bypass of AVIF_CLAMP in gain map tone mapping (use fminf/fmaxf) ## [1.4.1] - 2026-03-20 diff --git a/src/gainmap.c b/src/gainmap.c index 40a332fc2f..f56484d435 100644 --- a/src/gainmap.c +++ b/src/gainmap.c @@ -7,6 +7,14 @@ #include #include +// NaN-safe clamp to [0, 1]. AVIF_CLAMP passes NaN through because IEEE 754 +// comparisons with NaN always return false. fmaxf/fminf return the non-NaN +// argument per C99 §7.12.12, so this clamps NaN to 0. +static float avifNanSafeClamp(float val) +{ + return fminf(1.0f, fmaxf(0.0f, val)); +} + static void avifGainMapSetEncodingDefaults(avifGainMap * gainMap) { for (int i = 0; i < 3; ++i) { @@ -153,7 +161,7 @@ avifResult avifRGBImageApplyGainMap(const avifRGBImage * baseImage, avifLinearRGBConvertColorSpace(basePixelRGBA, conversionCoeffs); } for (int c = 0; c < 3; ++c) { - basePixelRGBA[c] = AVIF_CLAMP(linearToGamma(basePixelRGBA[c]), 0.0f, 1.0f); + basePixelRGBA[c] = avifNanSafeClamp(linearToGamma(basePixelRGBA[c])); } } avifSetRGBAPixel(toneMappedImage, i, j, &toneMappedPixelRGBInfo, basePixelRGBA); @@ -270,7 +278,12 @@ avifResult avifRGBImageApplyGainMap(const avifRGBImage * baseImage, } for (int c = 0; c < 3; ++c) { - toneMappedPixelRGBA[c] = AVIF_CLAMP(linearToGamma(toneMappedPixelRGBA[c]), 0.0f, 1.0f); + if (isnan(toneMappedPixelRGBA[c])) { + avifDiagnosticsPrintf(diag, "Degenerate gain map parameters produce NaN at pixel (%u, %u)", i, j); + res = AVIF_RESULT_INVALID_TONE_MAPPED_IMAGE; + goto cleanup; + } + toneMappedPixelRGBA[c] = avifNanSafeClamp(linearToGamma(toneMappedPixelRGBA[c])); } toneMappedPixelRGBA[3] = basePixelRGBA[3]; // Alpha is unaffected by tone mapping. @@ -762,7 +775,7 @@ avifResult avifRGBImageComputeGainMap(const avifRGBImage * baseRgbImage, float v = gainMapF[c][(size_t)j * width + i]; v = AVIF_CLAMP(v, gainMapMinLog2[c], gainMapMaxLog2[c]); v = powf((v - gainMapMinLog2[c]) / range, gainMapGamma); - gainMapF[c][(size_t)j * width + i] = AVIF_CLAMP(v, 0.0f, 1.0f); + gainMapF[c][(size_t)j * width + i] = avifNanSafeClamp(v); } } } diff --git a/tests/gtest/avifgainmaptest.cc b/tests/gtest/avifgainmaptest.cc index 8abd2265b0..7871bf4c32 100644 --- a/tests/gtest/avifgainmaptest.cc +++ b/tests/gtest/avifgainmaptest.cc @@ -1593,6 +1593,95 @@ TEST(FindMinMaxWithoutOutliers, Test) { } } +// Verify that applying a gain map with degenerate parameters that produce NaN +// (0 * Inf from black pixels with baseOffset=0 and large gainMapMax) returns +// AVIF_RESULT_INVALID_TONE_MAPPED_IMAGE instead of crashing or producing +// garbage output. +TEST(GainMapTest, ApplyGainMapNaN) { + // 2x2 black base image (sRGB, BT.709). + ImagePtr base(avifImageCreate(2, 2, 8, AVIF_PIXEL_FORMAT_YUV444)); + ASSERT_NE(base, nullptr); + base->colorPrimaries = AVIF_COLOR_PRIMARIES_SRGB; + base->transferCharacteristics = AVIF_TRANSFER_CHARACTERISTICS_SRGB; + base->matrixCoefficients = AVIF_MATRIX_COEFFICIENTS_BT709; + base->yuvRange = AVIF_RANGE_FULL; + ASSERT_EQ(avifImageAllocatePlanes(base.get(), AVIF_PLANES_YUV), + AVIF_RESULT_OK) + << "Failed to allocate base planes"; + // Y=0 (black), U=128/V=128 (neutral chroma). + memset(base->yuvPlanes[0], 0, (size_t)base->yuvRowBytes[0] * 2); + memset(base->yuvPlanes[1], 128, (size_t)base->yuvRowBytes[1] * 2); + memset(base->yuvPlanes[2], 128, (size_t)base->yuvRowBytes[2] * 2); + + // 2x2 gain map image — all pixels at maximum (255 -> 1.0 normalized). + GainMapPtr gainMap(avifGainMapCreate()); + ASSERT_NE(gainMap, nullptr); + gainMap->image = avifImageCreate(2, 2, 8, AVIF_PIXEL_FORMAT_YUV444); + ASSERT_NE(gainMap->image, nullptr) << "Failed to create gain map image"; + gainMap->image->yuvRange = AVIF_RANGE_FULL; + gainMap->image->matrixCoefficients = AVIF_MATRIX_COEFFICIENTS_IDENTITY; + ASSERT_EQ(avifImageAllocatePlanes(gainMap->image, AVIF_PLANES_YUV), + AVIF_RESULT_OK) + << "Failed to allocate gain map planes"; + memset(gainMap->image->yuvPlanes[0], 255, + (size_t)gainMap->image->yuvRowBytes[0] * 2); + memset(gainMap->image->yuvPlanes[1], 255, + (size_t)gainMap->image->yuvRowBytes[1] * 2); + memset(gainMap->image->yuvPlanes[2], 255, + (size_t)gainMap->image->yuvRowBytes[2] * 2); + + // Gain map metadata crafted to trigger NaN: + // gainMapMin = 0 -> lerp lower bound + // gainMapMax = 1000 -> lerp upper bound + // gamma = 1 -> no gamma distortion + // baseOffset = 0 -> (baseLinear + 0) = 0 for black pixels + // altOffset = 0 + // + // The math: lerp(0, 1000, powf(1.0, 1.0)) = 1000 + // exp2f(1000 * weight) = +Inf + // (0.0 + 0.0) * +Inf = NaN (IEEE 754) + for (int c = 0; c < 3; ++c) { + gainMap->gainMapMin[c] = {0, 1}; + gainMap->gainMapMax[c] = {1000, 1}; + gainMap->gainMapGamma[c] = {1, 1}; + gainMap->baseOffset[c] = {0, 1}; + gainMap->alternateOffset[c] = {0, 1}; + } + gainMap->baseHdrHeadroom = {0, 1}; + gainMap->alternateHdrHeadroom = {6, 1}; + gainMap->useBaseColorSpace = 1; + gainMap->altColorPrimaries = AVIF_COLOR_PRIMARIES_SRGB; + gainMap->altTransferCharacteristics = AVIF_TRANSFER_CHARACTERISTICS_SRGB; + gainMap->altMatrixCoefficients = AVIF_MATRIX_COEFFICIENTS_BT709; + gainMap->altYUVRange = AVIF_RANGE_FULL; + gainMap->altDepth = 8; + gainMap->altPlaneCount = 3; + + // Output tone-mapped image. + avifRGBImage toneMap; + memset(&toneMap, 0, sizeof(toneMap)); + toneMap.depth = 8; + toneMap.format = AVIF_RGB_FORMAT_RGBA; + + avifContentLightLevelInformationBox clli; + memset(&clli, 0, sizeof(clli)); + avifDiagnostics diag; + avifDiagnosticsClearError(&diag); + + // Apply with full HDR headroom (weight = 1.0). + // Use LINEAR transfer so NaN propagates through to the clamp check. + // (sRGB's gamma function absorbs NaN to 1.0f, hiding the issue.) + avifResult result = avifImageApplyGainMap(base.get(), gainMap.get(), 6.0f, + AVIF_COLOR_PRIMARIES_SRGB, + AVIF_TRANSFER_CHARACTERISTICS_LINEAR, + &toneMap, &clli, &diag); + + EXPECT_EQ(result, AVIF_RESULT_INVALID_TONE_MAPPED_IMAGE) + << avifResultToString(result) << " (" << diag.error << ")"; + + avifRGBImageFreePixels(&toneMap); +} + } // namespace } // namespace avif