Fix NaN bypass of AVIF_CLAMP in gain map pixel clamping (#3189)

Apply path (the two sites at lines 156 and 277): when `baseLinear + baseOffset` is zero (e.g., a black pixel with `baseOffset = {0, 1}`), and the gain map log2 value is large enough for `exp2f()` to overflow to +Inf, the multiplication `0.0f * +Inf` evaluates to NaN per IEEE 754. The NaN then passes through `linearToGamma()` and `AVIF_CLAMP()` unmodified, since the macro's ternary comparisons all return false for NaN operands.

A similar NaN path exists in the compute path (line 764): if `base + baseOffset[c]` is near-zero after color space conversion, the ratio at line 695 overflows to +Inf. `AVIF_MAX(ratio, kEpsilon)` only lower-bounds, so +Inf passes through into `gainMapF`. After `avifFindMinMaxWithoutOutliers` picks up +Inf as the channel max, the normalization `(+Inf - min) / +Inf` evaluates to NaN (indeterminate form).

The `fminf`/`fmaxf` fix is intentionally a safety net at the output boundary — it ensures NaN can't reach `avifSetRGBAPixel()` regardless of which upstream path produced it. If you'd prefer a root-cause fix as well (e.g., clamping the ratio or guarding `exp2f` overflow), I'm happy to add that.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 17349e7..7b3b942 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,7 @@
 * Update LocalAvm.cmake: research-v15.0.0
 * 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)
 * avifenc: reject mismatched --depth for Y4M input
 
 ## [1.4.1] - 2026-03-20
diff --git a/src/gainmap.c b/src/gainmap.c
index 40a332f..f56484d 100644
--- a/src/gainmap.c
+++ b/src/gainmap.c
@@ -7,6 +7,14 @@
 #include <math.h>
 #include <string.h>
 
+// 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 @@
                         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 @@
             }
 
             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 @@
                     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 8abd226..9f6cdd5 100644
--- a/tests/gtest/avifgainmaptest.cc
+++ b/tests/gtest/avifgainmaptest.cc
@@ -1593,6 +1593,94 @@
   }
 }
 
+// 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