Apply clean aperture crop when decoding to PNG or JPEG. (#3041)

#2427
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b886768..1fce729 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,7 @@
 * Support Sample Transform derived image items with grid input image items.
 * Add --sato flag to avifdec to enable Sample Transforms support at decoding.
 * Add --grid option to avifgainmaputil.
+* Apply clean aperture crop when decoding to PNG or JPEG.
 
 ### Changed since 1.3.0
 
diff --git a/apps/shared/avifjpeg.c b/apps/shared/avifjpeg.c
index cde4298..4b6fe9a 100644
--- a/apps/shared/avifjpeg.c
+++ b/apps/shared/avifjpeg.c
@@ -1664,20 +1664,30 @@
     cinfo.err = jpeg_std_error(&jerr);
     jpeg_create_compress(&cinfo);
 
-    avifRGBImage rgb;
-    avifRGBImageSetDefaults(&rgb, avif);
-    rgb.format = avif->yuvFormat == AVIF_PIXEL_FORMAT_YUV400 ? AVIF_RGB_FORMAT_GRAY : AVIF_RGB_FORMAT_RGB;
-    rgb.chromaUpsampling = chromaUpsampling;
-    rgb.depth = 8;
-    if (avifRGBImageAllocatePixels(&rgb) != AVIF_RESULT_OK) {
+    avifRGBImage rgbData;
+    avifRGBImageSetDefaults(&rgbData, avif);
+    rgbData.format = avif->yuvFormat == AVIF_PIXEL_FORMAT_YUV400 ? AVIF_RGB_FORMAT_GRAY : AVIF_RGB_FORMAT_RGB;
+    rgbData.chromaUpsampling = chromaUpsampling;
+    rgbData.depth = 8;
+    if (avifRGBImageAllocatePixels(&rgbData) != AVIF_RESULT_OK) {
         fprintf(stderr, "Conversion to RGB failed: %s (out of memory)\n", outputFilename);
         goto cleanup;
     }
-    if (avifImageYUVToRGB(avif, &rgb) != AVIF_RESULT_OK) {
+    if (avifImageYUVToRGB(avif, &rgbData) != AVIF_RESULT_OK) {
         fprintf(stderr, "Conversion to RGB failed: %s\n", outputFilename);
         goto cleanup;
     }
 
+    avifRGBImage rgbView = rgbData;
+    if (avif->transformFlags & AVIF_TRANSFORM_CLAP) {
+        avifCropRect cropRect;
+        avifDiagnostics diag;
+        if (avifCropRectFromCleanApertureBox(&cropRect, &avif->clap, avif->width, avif->height, &diag) &&
+            (cropRect.x != 0 || cropRect.y != 0 || cropRect.width != avif->width || cropRect.height != avif->height)) {
+            avifRGBImageSetViewRect(&rgbView, &rgbData, &cropRect);
+        }
+    }
+
     f = fopen(outputFilename, "wb");
     if (!f) {
         fprintf(stderr, "Can't open JPEG file for write: %s\n", outputFilename);
@@ -1685,8 +1695,8 @@
     }
 
     jpeg_stdio_dest(&cinfo, f);
-    cinfo.image_width = avif->width;
-    cinfo.image_height = avif->height;
+    cinfo.image_width = rgbView.width;
+    cinfo.image_height = rgbView.height;
     const avifBool isGray = avif->yuvFormat == AVIF_PIXEL_FORMAT_YUV400;
     cinfo.input_components = isGray ? 1 : 3;
     cinfo.in_color_space = isGray ? JCS_GRAYSCALE : JCS_RGB;
@@ -1699,21 +1709,6 @@
         write_icc_profile(&cinfo, avif->icc.data, (unsigned int)avif->icc.size);
     }
 
-    if (avif->transformFlags & AVIF_TRANSFORM_CLAP) {
-        avifCropRect cropRect;
-        avifDiagnostics diag;
-        if (avifCropRectFromCleanApertureBox(&cropRect, &avif->clap, avif->width, avif->height, &diag) &&
-            (cropRect.x != 0 || cropRect.y != 0 || cropRect.width != avif->width || cropRect.height != avif->height)) {
-            // TODO: https://github.com/AOMediaCodec/libavif/issues/2427 - Implement.
-            fprintf(stderr,
-                    "Warning: Clean Aperture values were ignored, the output image was NOT cropped to rectangle {%u,%u,%u,%u}\n",
-                    cropRect.x,
-                    cropRect.y,
-                    cropRect.width,
-                    cropRect.height);
-        }
-    }
-
     if (avif->exif.data && (avif->exif.size > 0)) {
         size_t exifTiffHeaderOffset;
         avifResult result = avifGetExifTiffHeaderOffset(avif->exif.data, avif->exif.size, &exifTiffHeaderOffset);
@@ -1787,7 +1782,7 @@
     }
 
     while (cinfo.next_scanline < cinfo.image_height) {
-        row_pointer[0] = &rgb.pixels[cinfo.next_scanline * rgb.rowBytes];
+        row_pointer[0] = &rgbView.pixels[cinfo.next_scanline * rgbView.rowBytes];
         (void)jpeg_write_scanlines(&cinfo, row_pointer, 1);
     }
 
@@ -1799,6 +1794,6 @@
         fclose(f);
     }
     jpeg_destroy_compress(&cinfo);
-    avifRGBImageFreePixels(&rgb);
+    avifRGBImageFreePixels(&rgbData);
     return ret;
 }
diff --git a/apps/shared/avifpng.c b/apps/shared/avifpng.c
index a332b18..46d278a 100644
--- a/apps/shared/avifpng.c
+++ b/apps/shared/avifpng.c
@@ -627,8 +627,8 @@
     png_bytep * volatile rowPointers = NULL;
     FILE * volatile f = NULL;
 
-    avifRGBImage rgb;
-    memset(&rgb, 0, sizeof(avifRGBImage));
+    avifRGBImage rgbData;
+    memset(&rgbData, 0, sizeof(avifRGBImage));
 
     volatile int rgbDepth = requestedDepth;
     if (rgbDepth == 0) {
@@ -651,39 +651,55 @@
         rgbDepth = 8;
     }
 
-    volatile avifBool monochrome8bit = (avif->yuvFormat == AVIF_PIXEL_FORMAT_YUV400) && !avif->alphaPlane && (avif->depth == 8) &&
-                                       (rgbDepth == 8);
+    volatile avifBool hasClap = avif->transformFlags & AVIF_TRANSFORM_CLAP;
+    volatile avifBool copyYPlane = (avif->yuvFormat == AVIF_PIXEL_FORMAT_YUV400) && !avif->alphaPlane && (avif->depth == 8) &&
+                                   (rgbDepth == 8) && !hasClap;
 
     volatile int colorType;
-    if (monochrome8bit) {
+    if (copyYPlane) {
         colorType = PNG_COLOR_TYPE_GRAY;
     } else {
-        avifRGBImageSetDefaults(&rgb, avif);
-        rgb.depth = rgbDepth;
+        avifRGBImageSetDefaults(&rgbData, avif);
+        rgbData.depth = rgbDepth;
         if (avif->yuvFormat == AVIF_PIXEL_FORMAT_YUV400 && avif->alphaPlane) {
             colorType = PNG_COLOR_TYPE_GRAY_ALPHA;
-            rgb.format = AVIF_RGB_FORMAT_GRAYA;
+            rgbData.format = AVIF_RGB_FORMAT_GRAYA;
         } else if (avif->yuvFormat == AVIF_PIXEL_FORMAT_YUV400 && !avif->alphaPlane) {
             colorType = PNG_COLOR_TYPE_GRAY;
-            rgb.format = AVIF_RGB_FORMAT_GRAY;
+            rgbData.format = AVIF_RGB_FORMAT_GRAY;
         } else {
-            rgb.chromaUpsampling = chromaUpsampling;
+            rgbData.chromaUpsampling = chromaUpsampling;
             colorType = PNG_COLOR_TYPE_RGBA;
             if (avifImageIsOpaque(avif)) {
                 colorType = PNG_COLOR_TYPE_RGB;
-                rgb.format = AVIF_RGB_FORMAT_RGB;
+                rgbData.format = AVIF_RGB_FORMAT_RGB;
             }
         }
-        if (avifRGBImageAllocatePixels(&rgb) != AVIF_RESULT_OK) {
+        if (avifRGBImageAllocatePixels(&rgbData) != AVIF_RESULT_OK) {
             fprintf(stderr, "Conversion to RGB failed: %s (out of memory)\n", outputFilename);
             goto cleanup;
         }
-        if (avifImageYUVToRGB(avif, &rgb) != AVIF_RESULT_OK) {
+        if (avifImageYUVToRGB(avif, &rgbData) != AVIF_RESULT_OK) {
             fprintf(stderr, "Conversion to RGB failed: %s\n", outputFilename);
             goto cleanup;
         }
     }
 
+    volatile uint32_t width = avif->width;
+    volatile uint32_t height = avif->height;
+
+    avifRGBImage rgbView = rgbData;
+    if (hasClap) {
+        avifCropRect cropRect;
+        avifDiagnostics diag;
+        if (avifCropRectFromCleanApertureBox(&cropRect, &avif->clap, avif->width, avif->height, &diag) &&
+            (cropRect.x != 0 || cropRect.y != 0 || cropRect.width != avif->width || cropRect.height != avif->height)) {
+            avifRGBImageSetViewRect(&rgbView, &rgbData, &cropRect);
+            width = cropRect.width;
+            height = cropRect.height;
+        }
+    }
+
     f = fopen(outputFilename, "wb");
     if (!f) {
         fprintf(stderr, "Can't open PNG file for write: %s\n", outputFilename);
@@ -721,7 +737,7 @@
         png_set_compression_level(png, compressionLevel);
     }
 
-    png_set_IHDR(png, info, avif->width, avif->height, rgbDepth, colorType, PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT);
+    png_set_IHDR(png, info, width, height, rgbDepth, colorType, PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT);
 
     const avifBool hasIcc = avif->icc.data && (avif->icc.size > 0);
     if (hasIcc) {
@@ -811,39 +827,25 @@
         png_write_chunk(png, cicp, cicpData, 4);
     }
 
-    rowPointers = (png_bytep *)malloc(sizeof(png_bytep) * avif->height);
+    rowPointers = (png_bytep *)malloc(sizeof(png_bytep) * height);
     if (rowPointers == NULL) {
         fprintf(stderr, "Error writing PNG: memory allocation failure");
         goto cleanup;
     }
     uint8_t * row;
     uint32_t rowBytes;
-    if (monochrome8bit) {
+    if (copyYPlane) {
         row = avif->yuvPlanes[AVIF_CHAN_Y];
         rowBytes = avif->yuvRowBytes[AVIF_CHAN_Y];
     } else {
-        row = rgb.pixels;
-        rowBytes = rgb.rowBytes;
+        row = rgbView.pixels;
+        rowBytes = rgbView.rowBytes;
     }
-    for (uint32_t y = 0; y < avif->height; ++y) {
+    for (uint32_t y = 0; y < height; ++y) {
         rowPointers[y] = row;
         row += rowBytes;
     }
 
-    if (avif->transformFlags & AVIF_TRANSFORM_CLAP) {
-        avifCropRect cropRect;
-        avifDiagnostics diag;
-        if (avifCropRectFromCleanApertureBox(&cropRect, &avif->clap, avif->width, avif->height, &diag) &&
-            (cropRect.x != 0 || cropRect.y != 0 || cropRect.width != avif->width || cropRect.height != avif->height)) {
-            // TODO: https://github.com/AOMediaCodec/libavif/issues/2427 - Implement.
-            fprintf(stderr,
-                    "Warning: Clean Aperture values were ignored, the output image was NOT cropped to rectangle {%u,%u,%u,%u}\n",
-                    cropRect.x,
-                    cropRect.y,
-                    cropRect.width,
-                    cropRect.height);
-        }
-    }
     if (avifImageGetExifOrientationFromIrotImir(avif) != 1) {
         // TODO: https://github.com/AOMediaCodec/libavif/issues/2427 - Rotate the samples.
         fprintf(stderr,
@@ -871,6 +873,6 @@
     if (rowPointers) {
         free(rowPointers);
     }
-    avifRGBImageFreePixels(&rgb);
+    avifRGBImageFreePixels(&rgbData);
     return writeResult;
 }
diff --git a/apps/shared/avifutil.c b/apps/shared/avifutil.c
index 0dd9e17..e7261d2 100644
--- a/apps/shared/avifutil.c
+++ b/apps/shared/avifutil.c
@@ -649,3 +649,21 @@
 
     return AVIF_TRUE;
 }
+
+void avifRGBImageSetViewRect(avifRGBImage * dstImage, const avifRGBImage * srcImage, const avifCropRect * cropRect)
+{
+    memset(dstImage, 0, sizeof(avifRGBImage));
+    dstImage->width = cropRect->width;
+    dstImage->height = cropRect->height;
+    dstImage->depth = srcImage->depth;
+    dstImage->format = srcImage->format;
+    dstImage->alphaPremultiplied = srcImage->alphaPremultiplied;
+    dstImage->isFloat = srcImage->isFloat;
+    const uint32_t channelCount = avifRGBFormatChannelCount(srcImage->format);
+    const uint32_t bytesPerChannel = srcImage->depth <= 8 ? 1 : 2;
+    const uint32_t bytesPerSample = srcImage->format == AVIF_RGB_FORMAT_RGB_565 ? 2 : channelCount * bytesPerChannel;
+    // This should not overflow if cropRect is a valid crop of the image.
+    const size_t offset = (size_t)cropRect->y * srcImage->rowBytes + (size_t)cropRect->x * bytesPerSample;
+    dstImage->pixels = srcImage->pixels + offset;
+    dstImage->rowBytes = srcImage->rowBytes;
+}
diff --git a/apps/shared/avifutil.h b/apps/shared/avifutil.h
index 5474dfe..3ea289e 100644
--- a/apps/shared/avifutil.h
+++ b/apps/shared/avifutil.h
@@ -59,6 +59,11 @@
 // The returned cells must be destroyed with avifImageDestroy().
 avifBool avifImageSplitGrid(const avifImage * gridSplitImage, uint32_t gridCols, uint32_t gridRows, avifImage ** gridCells);
 
+// Performs a shallow copy of a rectangular area of an RGB image. 'dstImage' does not own the pixel data.
+// Assumes that cropRect is a valid cropping rectangle for srcImage. This is true if it was obtained
+// using avifCropRectFromCleanApertureBox().
+void avifRGBImageSetViewRect(avifRGBImage * dstImage, const avifRGBImage * srcImage, const avifCropRect * cropRect);
+
 // This structure holds any timing data coming from source (typically non-AVIF) inputs being fed
 // into avifenc. If either or both values are 0, the timing is "invalid" / sentinel and the values
 // should be ignored. This structure is used to override the timing defaults in avifenc when the
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index bf38de2..dee2119 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -266,6 +266,7 @@
     add_cmd_test(test_cmd_progressive ${CMAKE_CURRENT_SOURCE_DIR}/data)
     add_cmd_test(test_cmd_stdin ${CMAKE_CURRENT_SOURCE_DIR}/data)
     add_cmd_test(test_cmd_targetsize ${CMAKE_CURRENT_SOURCE_DIR}/data)
+    add_cmd_test(test_cmd_transform ${CMAKE_CURRENT_SOURCE_DIR}/data)
 
     if(AVIF_ENABLE_JPEG_GAIN_MAP_CONVERSION)
         add_cmd_test(test_cmd_avifgainmaputil ${CMAKE_CURRENT_SOURCE_DIR}/data)
@@ -375,7 +376,8 @@
         if(AVIF_BUILD_APPS)
             # Disable all tests that use avifenc without explicitly setting --codec=avm.
             set_tests_properties(
-                test_cmd test_cmd_animation test_cmd_grid test_cmd_stdin test_cmd_targetsize PROPERTIES DISABLED True
+                test_cmd test_cmd_animation test_cmd_grid test_cmd_stdin test_cmd_targetsize test_cmd_transform
+                PROPERTIES DISABLED True
             )
         endif()
     endif()
diff --git a/tests/gtest/are_images_equal.cc b/tests/gtest/are_images_equal.cc
index e5d5e9e..02df18e 100644
--- a/tests/gtest/are_images_equal.cc
+++ b/tests/gtest/are_images_equal.cc
@@ -60,8 +60,10 @@
   if (argc == 4) {
     if (!avif::testutil::AreImagesEqual(*decoded[0], *decoded[1],
                                         ignore_alpha)) {
+      auto psnr =
+          avif::testutil::GetPsnr(*decoded[0], *decoded[1], ignore_alpha);
       std::cerr << "Images " << argv[1] << " and " << argv[2]
-                << " are different." << std::endl;
+                << " are different (PSNR: " << psnr << ")." << std::endl;
       return 1;
     }
     std::cout << "Images " << argv[1] << " and " << argv[2] << " are identical."
@@ -70,7 +72,8 @@
     auto psnr = avif::testutil::GetPsnr(*decoded[0], *decoded[1], ignore_alpha);
     if (psnr < std::stod(argv[4])) {
       std::cerr << "PSNR: " << psnr << ", images " << argv[1] << " and "
-                << argv[2] << " are not similar." << std::endl;
+                << argv[2] << " are not similar enough (threshold: " << argv[4]
+                << ")." << std::endl;
       return 1;
     }
     std::cout << "PSNR: " << psnr << ", images " << argv[1] << " and "
diff --git a/tests/test_cmd_icc_profile.sh b/tests/test_cmd_icc_profile.sh
index ad8d1a2..534f0ad 100755
--- a/tests/test_cmd_icc_profile.sh
+++ b/tests/test_cmd_icc_profile.sh
@@ -75,6 +75,7 @@
     popd
     exit 0
   fi
+  "${IMAGEMAGICK}" --version
 
   "${AVIFENC}" -s 8 -l "${INPUT_COLOR_PNG}" -o "${ENCODED_FILE}"
   # Old version of ImageMagick may not support reading ICC from AVIF.
diff --git a/tests/test_cmd_transform.sh b/tests/test_cmd_transform.sh
new file mode 100644
index 0000000..0f5617b
--- /dev/null
+++ b/tests/test_cmd_transform.sh
@@ -0,0 +1,65 @@
+#!/bin/bash
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ------------------------------------------------------------------------------
+#
+# tests for command lines using image transforms (clap/irot/imir...)
+
+source $(dirname "$0")/cmd_test_common.sh || exit
+
+# Input file paths.
+INPUT_PNG="${TESTDATA_DIR}/paris_exif_xmp_icc.jpg"
+# Output file names.
+ENCODED_FILE="encoded.avif"
+ENCODED_FILE_CLAP="encoded_clap.avif"
+
+# Cleanup
+cleanup() {
+  rm -f -r "${TMP_DIR}"
+}
+trap cleanup EXIT
+
+pushd ${TMP_DIR}
+  # Encode/decode uncropped image
+  # Some image magick versions may drop EXIF. Remove it preemptively to avoid
+  # ARE_IMAGES_EQUAL failing because of EXIF mismatch.
+  "${AVIFENC}" --ignore-exif -s 10 "${INPUT_PNG}" -o "${ENCODED_FILE}"
+  "${AVIFDEC}" "${ENCODED_FILE}" "${ENCODED_FILE}.png"
+
+  # Encode with crop
+  "${AVIFENC}" --ignore-exif -s 10 "${INPUT_PNG}" --crop 10,50,100,25 -o "${ENCODED_FILE_CLAP}"
+  # Decode to PNG
+  "${AVIFDEC}" "${ENCODED_FILE_CLAP}" "${ENCODED_FILE_CLAP}.png"
+  # Decode to JPEG
+  "${AVIFDEC}" "${ENCODED_FILE_CLAP}" -q 100 "${ENCODED_FILE_CLAP}.jpg"
+
+  if command -v magick &> /dev/null
+  then
+      IMAGEMAGICK="magick"
+  elif command -v convert &> /dev/null
+  then
+      IMAGEMAGICK="convert"
+  else
+      echo Missing ImageMagick, test skipped
+      popd
+      exit 0
+  fi
+  "${IMAGEMAGICK}" --version
+
+  "${IMAGEMAGICK}" "${ENCODED_FILE}.png" -crop 100x25+10+50 "${ENCODED_FILE}_cropped.png"
+  "${ARE_IMAGES_EQUAL}" "${ENCODED_FILE}_cropped.png" "${ENCODED_FILE_CLAP}.png" 0
+  "${ARE_IMAGES_EQUAL}" "${ENCODED_FILE}_cropped.png" "${ENCODED_FILE_CLAP}.jpg" 0 49
+popd
+
+exit 0