diff --git a/media/base/video_frame_converter_unittest.cc b/media/base/video_frame_converter_unittest.cc index ac6d2fbc5d581..0f9f8f26ee232 100644 --- a/media/base/video_frame_converter_unittest.cc +++ b/media/base/video_frame_converter_unittest.cc @@ -200,4 +200,185 @@ INSTANTIATE_TEST_SUITE_P(, PIXEL_FORMAT_NV12A), testing::Bool())); +// ----------------------------------------------------------------------------- +// POC: MergeUV floor-truncation under-write with odd destination visible size. +// +// When converting+scaling I420 -> NV12 with an odd destination visible height, +// internals::MergeUV() computes the UV plane dimensions with floor division +// (height/2) while every other component (VideoFrame::Rows/Columns, +// PlaneSizeInSamples, I4xxxScale, libyuv::I420ToNV12) uses ceil semantics. +// As a result the last UV row of the destination buffer is never written. +// +// In production (Windows MF VEA / Android NDK VEA) the destination buffer is +// freshly heap-allocated and NOT zero-initialised, so the unwritten row carries +// GPU-process heap residue straight into the hardware encoder and out via the +// encoded bitstream (CWE-908). +// +// This test pre-fills the destination UV plane with a sentinel (0xCC) to make +// the under-write directly observable on any platform: after ConvertAndScale, +// the last UV row still contains the sentinel. +// ----------------------------------------------------------------------------- +TEST(VideoFrameConverterMergeUVUnderwrite, OddHeightLeavesLastUVRowUnwritten) { + constexpr uint8_t kSentinel = 0xCC; + constexpr uint8_t kSrcChroma = 0x40; + + // Source: I420, visible 100x100 (even). Any size != dest visible size forces + // the scaling path (video_frame_converter.cc ConvertAndScaleI4xxx -> NV12). + const gfx::Size kSrcCoded(100, 100); + const gfx::Rect kSrcVisible(0, 0, 100, 100); + auto src = VideoFrame::CreateZeroInitializedFrame( + PIXEL_FORMAT_I420, kSrcCoded, kSrcVisible, kSrcVisible.size(), + base::TimeDelta()); + ASSERT_TRUE(src); + // Fill source U/V with a known constant so any deviation in dest UV is + // attributable to the under-write, not to scaling artefacts. + for (size_t p : {VideoFrame::Plane::kU, VideoFrame::Plane::kV}) { + const int rows = VideoFrame::Rows(p, PIXEL_FORMAT_I420, kSrcCoded.height()); + const int cols = + VideoFrame::Columns(p, PIXEL_FORMAT_I420, kSrcCoded.width()); + for (int y = 0; y < rows; ++y) { + UNSAFE_TODO(memset(src->writable_data(p) + y * src->stride(p), kSrcChroma, + cols)); + } + } + + // Destination: NV12, visible 100x101 (ODD HEIGHT). Backed by an external + // buffer pre-filled with the sentinel so we can detect unwritten regions. + const gfx::Size kDstCoded(100, 102); + const gfx::Rect kDstVisible(0, 0, 100, 101); + const size_t kYStride = 100; + const size_t kUVStride = 100; // 50 UV pairs * 2 bytes. + const size_t kYBytes = kYStride * kDstCoded.height(); // 100*102 + const size_t kUVBytes = kUVStride * (kDstCoded.height() / 2); // 100*51 + std::vector y_buf(kYBytes, kSentinel); + std::vector uv_buf(kUVBytes, kSentinel); + auto dst = VideoFrame::WrapExternalYuvData( + PIXEL_FORMAT_NV12, kDstCoded, kDstVisible, kDstVisible.size(), kYStride, + kUVStride, base::span(y_buf), + base::span(uv_buf), base::TimeDelta()); + ASSERT_TRUE(dst); + + // Expected UV-plane geometry (ceil semantics, matching what the HW encoder + // will read and what every other Chromium component computes): + const int kExpectedUVRows = + VideoFrame::Rows(VideoFrame::Plane::kUV, PIXEL_FORMAT_NV12, + kDstVisible.height()); // ceil(101/2) == 51 + const int kExpectedUVRowBytes = + VideoFrame::RowBytes(VideoFrame::Plane::kUV, PIXEL_FORMAT_NV12, + kDstVisible.width()); // 2*ceil(100/2) == 100 + ASSERT_EQ(kExpectedUVRows, 51); + ASSERT_EQ(kExpectedUVRowBytes, 100); + + VideoFrameConverter converter; + ASSERT_TRUE(converter.ConvertAndScale(*src, *dst).is_ok()); + + // Row 49 (the 50th row) MUST have been written: floor(101/2)==50 rows. + for (int x = 0; x < kExpectedUVRowBytes; ++x) { + ASSERT_EQ(uv_buf[49 * kUVStride + x], kSrcChroma) + << "row 49 col " << x << " was not written -- test setup is wrong"; + } + + // Row 50 (the 51st row) is the bug surface: with ceil semantics it MUST be + // written; with the floor-truncation bug it is left as sentinel. + int sentinel_bytes = 0; + for (int x = 0; x < kExpectedUVRowBytes; ++x) { + if (uv_buf[50 * kUVStride + x] == kSentinel) { + ++sentinel_bytes; + } + } + + fprintf(stderr, + "[POC] dest NV12 visible=%dx%d UV rows expected (ceil)=%d " + "MergeUV wrote (floor)=%d\n", + kDstVisible.width(), kDstVisible.height(), kExpectedUVRows, + kDstVisible.height() / 2); + fprintf(stderr, + "[POC] UV row 50 (last ceil row): %d / %d bytes still contain " + "sentinel 0x%02X\n", + sentinel_bytes, kExpectedUVRowBytes, kSentinel); + fprintf(stderr, "[POC] first 16 bytes of UV row 50: "); + for (int x = 0; x < 16; ++x) { + fprintf(stderr, "%02X ", uv_buf[50 * kUVStride + x]); + } + fprintf(stderr, "\n"); + + // The assertion below encodes CORRECT behaviour. It FAILS while the bug is + // present, demonstrating that 100 contiguous bytes of the destination UV + // plane -- which the hardware encoder will read -- were never initialised by + // ConvertAndScale(). + EXPECT_EQ(sentinel_bytes, 0) + << "BUG CONFIRMED: MergeUV floor-truncation left " << sentinel_bytes + << " bytes of the last UV row uninitialised. In the GPU-process VEA " + "(Windows MF / Android NDK) these bytes are fresh heap residue " + "encoded into the H.264/HEVC bitstream and returned to the renderer."; +} + +TEST(VideoFrameConverterMergeUVUnderwrite, OddWidthLeavesLastUVColumnUnwritten) { + constexpr uint8_t kSentinel = 0xCC; + constexpr uint8_t kSrcChroma = 0x40; + + const gfx::Size kSrcCoded(100, 100); + const gfx::Rect kSrcVisible(0, 0, 100, 100); + auto src = VideoFrame::CreateZeroInitializedFrame( + PIXEL_FORMAT_I420, kSrcCoded, kSrcVisible, kSrcVisible.size(), + base::TimeDelta()); + ASSERT_TRUE(src); + for (size_t p : {VideoFrame::Plane::kU, VideoFrame::Plane::kV}) { + const int rows = VideoFrame::Rows(p, PIXEL_FORMAT_I420, kSrcCoded.height()); + const int cols = + VideoFrame::Columns(p, PIXEL_FORMAT_I420, kSrcCoded.width()); + for (int y = 0; y < rows; ++y) { + UNSAFE_TODO(memset(src->writable_data(p) + y * src->stride(p), kSrcChroma, + cols)); + } + } + + // Destination: NV12, visible 101x100 (ODD WIDTH). + const gfx::Size kDstCoded(102, 100); + const gfx::Rect kDstVisible(0, 0, 101, 100); + const size_t kYStride = 102; + const size_t kUVStride = 102; // 51 UV pairs * 2 bytes. + const size_t kYBytes = kYStride * kDstCoded.height(); + const size_t kUVBytes = kUVStride * (kDstCoded.height() / 2); + std::vector y_buf(kYBytes, kSentinel); + std::vector uv_buf(kUVBytes, kSentinel); + auto dst = VideoFrame::WrapExternalYuvData( + PIXEL_FORMAT_NV12, kDstCoded, kDstVisible, kDstVisible.size(), kYStride, + kUVStride, base::span(y_buf), + base::span(uv_buf), base::TimeDelta()); + ASSERT_TRUE(dst); + + const int kExpectedUVRows = + VideoFrame::Rows(VideoFrame::Plane::kUV, PIXEL_FORMAT_NV12, + kDstVisible.height()); // 50 + const int kExpectedUVRowBytes = + VideoFrame::RowBytes(VideoFrame::Plane::kUV, PIXEL_FORMAT_NV12, + kDstVisible.width()); // 2*ceil(101/2) == 102 + ASSERT_EQ(kExpectedUVRows, 50); + ASSERT_EQ(kExpectedUVRowBytes, 102); + + VideoFrameConverter converter; + ASSERT_TRUE(converter.ConvertAndScale(*src, *dst).is_ok()); + + // The last UV pair (bytes 100-101) of every row should be written under ceil + // semantics; floor-truncation leaves them as sentinel. + int sentinel_bytes = 0; + for (int y = 0; y < kExpectedUVRows; ++y) { + if (uv_buf[y * kUVStride + 100] == kSentinel) { + ++sentinel_bytes; + } + if (uv_buf[y * kUVStride + 101] == kSentinel) { + ++sentinel_bytes; + } + } + fprintf(stderr, + "[POC] dest NV12 visible=%dx%d last-column sentinel bytes: %d / %d\n", + kDstVisible.width(), kDstVisible.height(), sentinel_bytes, + 2 * kExpectedUVRows); + + EXPECT_EQ(sentinel_bytes, 0) + << "BUG CONFIRMED: MergeUV floor-truncation left the last UV column " + "uninitialised across " << kExpectedUVRows << " rows."; +} + } // namespace media