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),