Clamp samples in y4mRead()

Passing out-of-range values to libavif API may trigger undefined
behavior. Rather than fixing the whole pipeline and maybe slowing it
down, make sure the input respects the specified depth.
diff --git a/apps/shared/y4m.c b/apps/shared/y4m.c
index 05f6cdc..b573a30 100644
--- a/apps/shared/y4m.c
+++ b/apps/shared/y4m.c
@@ -5,6 +5,7 @@
 
 #include "y4m.h"
 
+#include <assert.h>
 #include <inttypes.h>
 #include <stdio.h>
 #include <stdlib.h>
@@ -201,6 +202,40 @@
     return -1;
 }
 
+// Limits each sample value to fit into avif->depth bits.
+// Returns AVIF_TRUE if any sample was clamped this way.
+static avifBool y4mClampSamples(avifImage * avif)
+{
+    if (!avifImageUsesU16(avif)) {
+        assert(avif->depth == 8);
+        return AVIF_FALSE;
+    }
+    assert(avif->depth < 16); // Otherwise it could be skipped too.
+
+    // AV1 encoders and decoders do not care whether the samples are full range or limited range
+    // for the internal computation: it is only passed as an informative tag, so ignore avif->yuvRange.
+    const uint16_t maxSampleValue = (uint16_t)((1u << avif->depth) - 1u);
+
+    avifBool samplesWereClamped = AVIF_FALSE;
+    for (int plane = AVIF_CHAN_Y; plane <= AVIF_CHAN_A; ++plane) {
+        uint32_t planeHeight = avifImagePlaneHeight(avif, plane); // 0 for UV if 4:0:0.
+        uint32_t planeWidth = avifImagePlaneWidth(avif, plane);
+        uint8_t * row = avifImagePlane(avif, plane);
+        uint32_t rowBytes = avifImagePlaneRowBytes(avif, plane);
+        for (uint32_t y = 0; y < planeHeight; ++y) {
+            uint16_t * row16 = (uint16_t *)row;
+            for (uint32_t x = 0; x < planeWidth; ++x) {
+                if (row16[x] > maxSampleValue) {
+                    row16[x] = maxSampleValue;
+                    samplesWereClamped = AVIF_TRUE;
+                }
+            }
+            row += rowBytes;
+        }
+    }
+    return samplesWereClamped;
+}
+
 #define ADVANCE(BYTES)    \
     do {                  \
         p += BYTES;       \
@@ -380,6 +415,12 @@
         }
     }
 
+    // libavif API does not guarantee the absence of undefined behavior if samples exceed the specified avif->depth.
+    // Avoid that by making sure input values are within the correct range.
+    if (y4mClampSamples(avif)) {
+        fprintf(stderr, "WARNING: some samples were clamped to fit into %u bits per sample\n", avif->depth);
+    }
+
     result = AVIF_TRUE;
 cleanup:
     if (iter) {
diff --git a/tests/gtest/aviftest_helpers.cc b/tests/gtest/aviftest_helpers.cc
index 7cfd87e..daa2cc9 100644
--- a/tests/gtest/aviftest_helpers.cc
+++ b/tests/gtest/aviftest_helpers.cc
@@ -91,6 +91,7 @@
 }
 
 void FillImageGradient(avifImage* image) {
+  assert(image->yuvRange == AVIF_RANGE_FULL);
   for (avifChannelIndex c :
        {AVIF_CHAN_Y, AVIF_CHAN_U, AVIF_CHAN_V, AVIF_CHAN_A}) {
     const uint32_t plane_width = avifImagePlaneWidth(image, c);
diff --git a/tests/gtest/avify4mtest.cc b/tests/gtest/avify4mtest.cc
index fa64c71..a5b384f 100644
--- a/tests/gtest/avify4mtest.cc
+++ b/tests/gtest/avify4mtest.cc
@@ -29,9 +29,9 @@
   const avifRange yuv_range = std::get<4>(GetParam());
   const bool create_alpha = std::get<5>(GetParam());
   std::ostringstream file_path;
-  file_path << testing::TempDir() << "avify4mtest_" << width << "_" << height
-            << "_" << bit_depth << "_" << yuv_format << "_" << yuv_range << "_"
-            << create_alpha;
+  file_path << testing::TempDir() << "avify4mtest_encodedecode_" << width << "_"
+            << height << "_" << bit_depth << "_" << yuv_format << "_"
+            << yuv_range << "_" << create_alpha;
 
   testutil::AvifImagePtr image = testutil::CreateImage(
       width, height, bit_depth, yuv_format,
@@ -56,6 +56,46 @@
   EXPECT_TRUE(testutil::AreImagesEqual(*image, *decoded));
 }
 
+TEST_P(Y4mTest, OutOfRange) {
+  const int width = std::get<0>(GetParam());
+  const int height = std::get<1>(GetParam());
+  const int bit_depth = std::get<2>(GetParam());
+  const avifPixelFormat yuv_format = std::get<3>(GetParam());
+  const avifRange yuv_range = std::get<4>(GetParam());
+  const bool create_alpha = std::get<5>(GetParam());
+  std::ostringstream file_path;
+  file_path << testing::TempDir() << "avify4mtest_outofrange_" << width << "_"
+            << height << "_" << bit_depth << "_" << yuv_format << "_"
+            << yuv_range << "_" << create_alpha;
+
+  testutil::AvifImagePtr image = testutil::CreateImage(
+      width, height, bit_depth, yuv_format,
+      create_alpha ? AVIF_PLANES_ALL : AVIF_PLANES_YUV, yuv_range);
+  ASSERT_NE(image, nullptr);
+
+  // Insert values that may be out-of-range on purpose compared to the specified
+  // bit_depth and yuv_range.
+  const uint32_t yuva8[] = {255, 0, 255, 255};
+  const uint32_t yuva16[] = {0, (1u << 16) - 1u, 0, (1u << 16) - 1u};
+  testutil::FillImagePlain(image.get(),
+                           avifImageUsesU16(image.get()) ? yuva16 : yuva8);
+  ASSERT_TRUE(y4mWrite(file_path.str().c_str(), image.get()));
+
+  // y4mRead() should clamp the values to respect the specified depth in order
+  // to avoid computation with unexpected sample values. However, it does not
+  // respect the limited ("video") range because the libavif API just passes
+  // that tag along, it is ignored by the compression algorithm.
+  testutil::AvifImagePtr decoded(avifImageCreateEmpty(), avifImageDestroy);
+  ASSERT_NE(decoded, nullptr);
+  ASSERT_TRUE(y4mRead(file_path.str().c_str(), decoded.get(),
+                      /*sourceTiming=*/nullptr, /*iter=*/nullptr));
+
+  // Pass it through the libavif API to make sure reading a bad y4m does not
+  // trigger undefined behavior.
+  const testutil::AvifRwData encoded = testutil::Encode(image.get());
+  EXPECT_NE(testutil::Decode(encoded.data, encoded.size), nullptr);
+}
+
 INSTANTIATE_TEST_SUITE_P(
     OpaqueCombinations, Y4mTest,
     Combine(/*width=*/Values(1, 2, 3),