Apply rotation and mirroring when decoding to PNG or JPEG. (#3059)

#2427
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 70de388..ed609cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,7 +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.
+* Apply clean aperture crop, rotation and mirror when decoding to PNG or JPEG.
 
 ### Changed since 1.3.0
 
diff --git a/apps/shared/avifjpeg.c b/apps/shared/avifjpeg.c
index 4b6fe9a..5491186 100644
--- a/apps/shared/avifjpeg.c
+++ b/apps/shared/avifjpeg.c
@@ -1678,13 +1678,15 @@
         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);
+    // rgbView is a view on rgbData. avifApplyTransforms() may modify rgbData.
+    avifRGBImage rgbView;
+    avifResult transformResult = avifApplyTransforms(&rgbView, &rgbData, avif);
+    if (transformResult != AVIF_RESULT_OK) {
+        if (transformResult == AVIF_RESULT_INVALID_ARGUMENT) {
+            fprintf(stderr, "Warning, ignoring invalid transforms (clap/irot/imir)\n");
+        } else {
+            fprintf(stderr, "Failed to apply transforms: %s\n", avifResultToString(transformResult));
+            goto cleanup;
         }
     }
 
@@ -1724,15 +1726,14 @@
         }
         memcpy(exif.data, AVIF_JPEG_EXIF_HEADER, AVIF_JPEG_EXIF_HEADER_LENGTH);
         memcpy(exif.data + AVIF_JPEG_EXIF_HEADER_LENGTH, avif->exif.data + exifTiffHeaderOffset, avif->exif.size - exifTiffHeaderOffset);
-        // Make sure the Exif orientation matches the irot/imir values.
-        // libheif does not have the same behavior. The orientation is applied to samples and orientation data is discarded there,
-        // see https://github.com/strukturag/libheif/blob/ea78603d8e47096606813d221725621306789ff2/examples/encoder_jpeg.cc#L187
-        const uint8_t orientation = avifImageGetExifOrientationFromIrotImir(avif);
-        result = avifSetExifOrientation(&exif, orientation);
+        // We already rotated the pixels if necessary in avifApplyTransforms(), so we set the orientation to 1 (no rotation, no mirror).
+        result = avifSetExifOrientation(&exif, 1);
         if (result != AVIF_RESULT_OK) {
-            // Ignore errors if the orientation is the default one because not being able to set Exif orientation now
-            // means a reader will not be able to parse it later either.
-            if (orientation != 1) {
+            if (result == AVIF_RESULT_INVALID_EXIF_PAYLOAD || result == AVIF_RESULT_NOT_IMPLEMENTED) {
+                // Either the Exif is invalid, or it doesn't have an orientation field.
+                // If it's invalid, we can consider it as equivalent to not having an orientation.
+                // In both cases, we can ignore the error.
+            } else {
                 fprintf(stderr, "Error writing JPEG metadata: %s\n", avifResultToString(result));
                 avifRWDataFree(&exif);
                 goto cleanup;
@@ -1747,12 +1748,6 @@
         }
         jpeg_write_marker(&cinfo, JPEG_APP0 + 1, remainingExif.data, (unsigned int)remainingExif.size);
         avifRWDataFree(&exif);
-    } else if (avifImageGetExifOrientationFromIrotImir(avif) != 1) {
-        // There is no Exif yet, but we need to store the orientation.
-        // TODO: https://github.com/AOMediaCodec/libavif/issues/2427 - Add a valid Exif payload or rotate the samples.
-        fprintf(stderr,
-                "Warning: Orientation %u was ignored, the output image was NOT rotated or mirrored\n",
-                avifImageGetExifOrientationFromIrotImir(avif));
     }
 
     if (avif->xmp.data && (avif->xmp.size > 0)) {
diff --git a/apps/shared/avifpng.c b/apps/shared/avifpng.c
index 46d278a..3e6b034 100644
--- a/apps/shared/avifpng.c
+++ b/apps/shared/avifpng.c
@@ -651,9 +651,9 @@
         rgbDepth = 8;
     }
 
-    volatile avifBool hasClap = avif->transformFlags & AVIF_TRANSFORM_CLAP;
+    volatile avifBool hasTransforms = avif->transformFlags & (AVIF_TRANSFORM_CLAP | AVIF_TRANSFORM_IROT | AVIF_TRANSFORM_IMIR);
     volatile avifBool copyYPlane = (avif->yuvFormat == AVIF_PIXEL_FORMAT_YUV400) && !avif->alphaPlane && (avif->depth == 8) &&
-                                   (rgbDepth == 8) && !hasClap;
+                                   (rgbDepth == 8) && !hasTransforms;
 
     volatile int colorType;
     if (copyYPlane) {
@@ -685,18 +685,15 @@
         }
     }
 
-    volatile uint32_t width = avif->width;
-    volatile uint32_t height = avif->height;
-
+    // rgbView is a view on rgbData. avifApplyTransforms() may modify rgbData.
     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;
+    avifResult transformResult = avifApplyTransforms(&rgbView, &rgbData, avif);
+    if (transformResult != AVIF_RESULT_OK) {
+        if (transformResult == AVIF_RESULT_INVALID_ARGUMENT) {
+            fprintf(stderr, "Warning, ignoring invalid transforms (clap/irot/imir)\n");
+        } else {
+            fprintf(stderr, "Failed to apply transforms: %s\n", avifResultToString(transformResult));
+            goto cleanup;
         }
     }
 
@@ -737,6 +734,9 @@
         png_set_compression_level(png, compressionLevel);
     }
 
+    volatile uint32_t width = copyYPlane ? avif->width : rgbView.width;
+    volatile uint32_t height = copyYPlane ? avif->height : rgbView.height;
+
     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);
@@ -846,13 +846,6 @@
         row += rowBytes;
     }
 
-    if (avifImageGetExifOrientationFromIrotImir(avif) != 1) {
-        // TODO: https://github.com/AOMediaCodec/libavif/issues/2427 - Rotate the samples.
-        fprintf(stderr,
-                "Warning: Orientation %u was ignored, the output image was NOT rotated or mirrored\n",
-                avifImageGetExifOrientationFromIrotImir(avif));
-    }
-
     if (rgbDepth > 8) {
         png_set_swap(png);
     }
diff --git a/apps/shared/avifutil.c b/apps/shared/avifutil.c
index e7261d2..5476b5e 100644
--- a/apps/shared/avifutil.c
+++ b/apps/shared/avifutil.c
@@ -659,11 +659,152 @@
     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;
+    const uint32_t bytesPerPixel = avifRGBImagePixelSize(srcImage);
     // 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;
+    const size_t offset = (size_t)cropRect->y * srcImage->rowBytes + (size_t)cropRect->x * bytesPerPixel;
     dstImage->pixels = srcImage->pixels + offset;
     dstImage->rowBytes = srcImage->rowBytes;
 }
+
+// NOTE: this saves the rotated pixels to a different image. Rotating an image in place is possible, but can be non trivial depending on the angle.
+// A 90° rotation can be implemented as a transposition operation followed by mirroring.
+// It's the transposition step that is non trivial for non-square images, see https://en.wikipedia.org/wiki/In-place_matrix_transposition
+avifResult avifRGBImageRotate(avifRGBImage * dstImage, const avifRGBImage * srcImage, const avifImageRotation * rotation)
+{
+    const uint32_t bytesPerPixel = avifRGBImagePixelSize(srcImage);
+    const uint8_t angle = rotation->angle;
+    const uint32_t newWidth = (angle == 0 || angle == 2) ? srcImage->width : srcImage->height;
+    const uint32_t newHeight = (angle == 0 || angle == 2) ? srcImage->height : srcImage->width;
+    *dstImage = *srcImage;
+    dstImage->width = newWidth;
+    dstImage->height = newHeight;
+    dstImage->pixels = NULL;
+    avifResult result = avifRGBImageAllocatePixels(dstImage);
+    if (result != AVIF_RESULT_OK) {
+        return result;
+    }
+
+    if (rotation->angle == 0) {
+        const size_t bytesPerRow = (size_t)bytesPerPixel * srcImage->width;
+        // 0 degrees. Just copy the rows as is.
+        for (uint32_t j = 0; j < srcImage->height; ++j) {
+            memcpy(dstImage->pixels + ((size_t)j * dstImage->rowBytes), srcImage->pixels + ((size_t)j * srcImage->rowBytes), bytesPerRow);
+        }
+    } else if (rotation->angle == 1) {
+        // 90 degrees anti-clockwise.
+        for (uint32_t j = 0; j < srcImage->height; ++j) {
+            for (uint32_t i = 0; i < srcImage->width; ++i) {
+                // Source pixel at (i, j) goes to destination pixel at (j, srcImage->width - 1 - i).
+                memcpy(dstImage->pixels + ((size_t)(srcImage->width - 1 - i) * dstImage->rowBytes) + ((size_t)j * bytesPerPixel),
+                       srcImage->pixels + ((size_t)j * srcImage->rowBytes) + ((size_t)i * bytesPerPixel),
+                       bytesPerPixel);
+            }
+        }
+    } else if (rotation->angle == 2) {
+        // 180 degrees.
+        for (uint32_t j = 0; j < srcImage->height; ++j) {
+            for (uint32_t i = 0; i < srcImage->width; ++i) {
+                // Source pixel at (i, j) goes to destination pixel at (srcImage->width - 1 - i, srcImage->height - 1 - j).
+                memcpy(dstImage->pixels + ((size_t)(srcImage->height - 1 - j) * dstImage->rowBytes) +
+                           ((size_t)(srcImage->width - 1 - i) * bytesPerPixel),
+                       srcImage->pixels + ((size_t)j * srcImage->rowBytes) + ((size_t)i * bytesPerPixel),
+                       bytesPerPixel);
+            }
+        }
+    } else if (rotation->angle == 3) {
+        // 90 degrees clockwise.
+        for (uint32_t j = 0; j < srcImage->height; ++j) {
+            for (uint32_t i = 0; i < srcImage->width; ++i) {
+                // Source pixel at (i, j) goes to destination pixel at (srcImage->width - 1 - i, j).
+                memcpy(dstImage->pixels + ((size_t)i * dstImage->rowBytes) + ((size_t)(srcImage->height - 1 - j) * bytesPerPixel),
+                       srcImage->pixels + ((size_t)j * srcImage->rowBytes) + ((size_t)i * bytesPerPixel),
+                       bytesPerPixel);
+            }
+        }
+    } else {
+        return AVIF_RESULT_INVALID_ARGUMENT; // Invalid angle.
+    }
+    return AVIF_RESULT_OK;
+}
+
+avifResult avifRGBImageMirror(avifRGBImage * image, const avifImageMirror * mirror)
+{
+    if (mirror->axis == 0) { // Horizontal axis.
+        const uint32_t bytesPerPixel = avifRGBImagePixelSize(image);
+        // May be less than image->rowBytes e.g. if image is a cropped view.
+        const size_t bytesPerRowToMove = (size_t)bytesPerPixel * image->width;
+        // Top-to-bottom
+        uint8_t * tempRow = (uint8_t *)avifAlloc(bytesPerRowToMove);
+        if (!tempRow) {
+            return AVIF_RESULT_OUT_OF_MEMORY;
+        }
+        for (uint32_t y = 0; y < image->height / 2; ++y) {
+            uint8_t * row1 = &image->pixels[(size_t)y * image->rowBytes];
+            uint8_t * row2 = &image->pixels[(size_t)(image->height - 1 - y) * image->rowBytes];
+            memcpy(tempRow, row1, bytesPerRowToMove);
+            memcpy(row1, row2, bytesPerRowToMove);
+            memcpy(row2, tempRow, bytesPerRowToMove);
+        }
+        avifFree(tempRow);
+    } else if (mirror->axis == 1) { // Vertical axis.
+        const uint32_t bytesPerPixel = avifRGBImagePixelSize(image);
+        uint8_t tempPixel[8]; // Max pixel size should be 8 bytes (RGBA 16-bit).
+        if (bytesPerPixel > sizeof(tempPixel)) {
+            return AVIF_RESULT_INVALID_ARGUMENT;
+        }
+        for (uint32_t y = 0; y < image->height; ++y) {
+            uint8_t * row = &image->pixels[(size_t)y * image->rowBytes];
+            for (uint32_t x = 0; x < image->width / 2; ++x) {
+                uint8_t * pixel1 = &row[(size_t)x * bytesPerPixel];
+                uint8_t * pixel2 = &row[(size_t)(image->width - 1 - x) * bytesPerPixel];
+                memcpy(tempPixel, pixel1, bytesPerPixel);
+                memcpy(pixel1, pixel2, bytesPerPixel);
+                memcpy(pixel2, tempPixel, bytesPerPixel);
+            }
+        }
+    } else {
+        return AVIF_RESULT_INVALID_ARGUMENT; // Invalid axis value.
+    }
+
+    return AVIF_RESULT_OK;
+}
+
+avifResult avifApplyTransforms(avifRGBImage * dstView, avifRGBImage * srcImage, const avifImage * avif)
+{
+    // ISO/IEC 23000-22 (MIAF), Section 7.3.6.7:
+    //  These properties, if used, shall be indicated to be applied in the following order:
+    //  clean aperture first, then rotation, then mirror.
+    *dstView = *srcImage;
+    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(dstView, srcImage, &cropRect);
+        } else {
+            fprintf(stderr, "Invalid clean aperture box\n");
+            return AVIF_RESULT_INVALID_ARGUMENT;
+        }
+    }
+    if (avif->transformFlags & AVIF_TRANSFORM_IROT && avif->irot.angle != 0) {
+        avifRGBImage tmpRgbImage;
+        avifResult result = avifRGBImageRotate(&tmpRgbImage, dstView, &avif->irot);
+        if (result != AVIF_RESULT_OK) {
+            fprintf(stderr, "Failed to apply rotation\n");
+            avifRGBImageFreePixels(&tmpRgbImage);
+            return result;
+        }
+        // We assume that srcImage owned its pixels and free them before replacing it with tmpRgbImage.
+        avifRGBImageFreePixels(srcImage);
+        *srcImage = tmpRgbImage;
+        *dstView = *srcImage;
+    }
+    if (avif->transformFlags & AVIF_TRANSFORM_IMIR) {
+        avifResult result = avifRGBImageMirror(dstView, &avif->imir);
+        if (result != AVIF_RESULT_OK) {
+            fprintf(stderr, "Failed to apply mirror\n");
+            return result;
+        }
+    }
+    return AVIF_RESULT_OK;
+}
diff --git a/apps/shared/avifutil.h b/apps/shared/avifutil.h
index 3ea289e..eaafd07 100644
--- a/apps/shared/avifutil.h
+++ b/apps/shared/avifutil.h
@@ -64,6 +64,19 @@
 // using avifCropRectFromCleanApertureBox().
 void avifRGBImageSetViewRect(avifRGBImage * dstImage, const avifRGBImage * srcImage, const avifCropRect * cropRect);
 
+// Rotates srcImage into dstImage. The two pointers must be different (does not rotate in place).
+// Allocates the pixels of dstImage which must be freed by the caller.
+avifResult avifRGBImageRotate(avifRGBImage * dstImage, const avifRGBImage * srcImage, const avifImageRotation * rotation);
+
+// Mirrors the image in place.
+avifResult avifRGBImageMirror(avifRGBImage * image, const avifImageMirror * mirror);
+
+// Applies clap, irot and imir transforms (in this order) specified in the avif image to srcImage.
+// The result is in dstView which does not own its pixels.
+// srcImage may also have its pixel data modified. Assumes that srcImage DOES own its pixels.
+// If an error is returned, only some of the transforms may have been applied.
+avifResult avifApplyTransforms(avifRGBImage * dstView, avifRGBImage * srcImage, const avifImage * avif);
+
 // 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/gtest/avifmetadatatest.cc b/tests/gtest/avifmetadatatest.cc
index 56faf11..c7f60d1 100644
--- a/tests/gtest/avifmetadatatest.cc
+++ b/tests/gtest/avifmetadatatest.cc
@@ -265,26 +265,26 @@
   EXPECT_EQ(decoded->irot.angle, 1u);
   EXPECT_EQ(decoded->imir.axis, 0u);
 
-  // Exif orientation is kept in JPEG export.
+  // JPEG: Exif orientation should be applied when saving and removed from Exif.
   ImagePtr temp_image =
       WriteAndReadImage(*image, "paris_exif_orientation_5.jpg");
   ASSERT_NE(temp_image, nullptr);
-  EXPECT_TRUE(testutil::AreByteSequencesEqual(image->exif, temp_image->exif));
-  EXPECT_EQ(image->transformFlags, temp_image->transformFlags);
-  EXPECT_EQ(image->irot.angle, temp_image->irot.angle);
-  EXPECT_EQ(image->imir.axis, temp_image->imir.axis);
-  EXPECT_EQ(image->width, temp_image->width);  // Samples are left untouched.
+  EXPECT_FALSE(testutil::AreByteSequencesEqual(image->exif, temp_image->exif));
+  EXPECT_EQ(temp_image->transformFlags, AVIF_TRANSFORM_NONE);
+  // Samples have been rotated.
+  EXPECT_EQ(image->width, temp_image->height);
+  EXPECT_EQ(image->height, temp_image->width);
 
-  // Exif orientation in PNG export should be ignored or discarded.
+  // PNG: Exif orientation should be applied when saving and removed from Exif.
   temp_image = WriteAndReadImage(*image, "paris_exif_orientation_5.png");
   ASSERT_NE(temp_image, nullptr);
   EXPECT_FALSE(testutil::AreByteSequencesEqual(image->exif, temp_image->exif));
   EXPECT_EQ(
       temp_image->transformFlags & (AVIF_TRANSFORM_IROT | AVIF_TRANSFORM_IMIR),
       avifTransformFlags{0});
-  // TODO: https://github.com/AOMediaCodec/libavif/issues/2427 - Fix orientation
-  // not being applied to PNG samples.
-  EXPECT_EQ(image->width, temp_image->width /* should be height here */);
+  // Samples have been rotated.
+  EXPECT_EQ(image->width, temp_image->height);
+  EXPECT_EQ(image->height, temp_image->width);
 }
 
 TEST(MetadataTest, AllExifOrientations) {
@@ -325,15 +325,16 @@
   EXPECT_EQ(decoded->irot.angle, 0u);
   EXPECT_EQ(decoded->imir.axis, image->imir.axis);
 
-  // Exif orientation is set equivalent to irot/imir in JPEG export.
-  // Existing Exif orientation is overwritten.
+  // Exif orientation is discarded (set to 1) and samples are rotated/mirrored
+  // according to irot/imir in JPEG export.
   const ImagePtr temp_image =
       WriteAndReadImage(*image, "paris_exif_orientation_2.jpg");
   ASSERT_NE(temp_image, nullptr);
   EXPECT_FALSE(testutil::AreByteSequencesEqual(image->exif, temp_image->exif));
-  EXPECT_EQ(image->transformFlags, temp_image->transformFlags);
-  EXPECT_EQ(image->imir.axis, temp_image->imir.axis);
-  EXPECT_EQ(image->width, temp_image->width);  // Samples are left untouched.
+  EXPECT_EQ(temp_image->transformFlags, AVIF_TRANSFORM_NONE);
+  // Samples have been mirrored but you can't really tell from the dimensions.
+  EXPECT_EQ(image->width, temp_image->width);
+  EXPECT_EQ(image->height, temp_image->height);
 }
 
 TEST(MetadataTest, RotatedJpegBecauseOfIrotImir) {
@@ -356,9 +357,9 @@
   EXPECT_EQ(
       temp_image->transformFlags & (AVIF_TRANSFORM_IROT | AVIF_TRANSFORM_IMIR),
       avifTransformFlags{0});
-  // TODO: https://github.com/AOMediaCodec/libavif/issues/2427 - Fix orientation
-  // not being applied to JPEG samples.
-  EXPECT_EQ(image->width, temp_image->width /* should be height here */);
+  // Samples have been rotated.
+  EXPECT_EQ(image->width, temp_image->height);
+  EXPECT_EQ(image->height, temp_image->width);
 }
 
 TEST(MetadataTest, ExifIfdOffsetLoopingTo8) {
diff --git a/tests/test_cmd_transform.sh b/tests/test_cmd_transform.sh
index 0f5617b..75d72af 100755
--- a/tests/test_cmd_transform.sh
+++ b/tests/test_cmd_transform.sh
@@ -22,7 +22,6 @@
 INPUT_PNG="${TESTDATA_DIR}/paris_exif_xmp_icc.jpg"
 # Output file names.
 ENCODED_FILE="encoded.avif"
-ENCODED_FILE_CLAP="encoded_clap.avif"
 
 # Cleanup
 cleanup() {
@@ -37,12 +36,51 @@
   "${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"
+  # Crop
+  "${AVIFENC}" --ignore-exif -s 10 "${INPUT_PNG}" --crop 10,50,100,25 -o "encoded_clap.avif"
+  "${AVIFDEC}" "encoded_clap.avif" "encoded_clap.png"
+  "${AVIFDEC}" "encoded_clap.avif" -q 100 "encoded_clap.jpg"
+
+  # Rotation (0 degrees)
+  "${AVIFENC}" --ignore-exif -s 10 "${INPUT_PNG}" --irot 0 -o "encoded_irot0.avif"
+  "${AVIFDEC}" "encoded_irot0.avif" "encoded_irot0.png"
+  "${AVIFDEC}" "encoded_irot0.avif" -q 100 "encoded_irot0.jpg"
+
+  # Rotation (90 degrees anti-clockwise)
+  "${AVIFENC}" --ignore-exif -s 10 "${INPUT_PNG}" --irot 1 -o "encoded_irot1.avif"
+  "${AVIFDEC}" "encoded_irot1.avif" "encoded_irot1.png"
+  "${AVIFDEC}" "encoded_irot1.avif" -q 100 "encoded_irot1.jpg"
+
+  # Rotation (180 degrees)
+  "${AVIFENC}" --ignore-exif -s 10 "${INPUT_PNG}" --irot 2 -o "encoded_irot2.avif"
+  "${AVIFDEC}" "encoded_irot2.avif" "encoded_irot2.png"
+  "${AVIFDEC}" "encoded_irot2.avif" -q 100 "encoded_irot2.jpg"
+
+  # Rotation (90 clockwise)
+  "${AVIFENC}" --ignore-exif -s 10 "${INPUT_PNG}" --irot 3 -o "encoded_irot3.avif"
+  "${AVIFDEC}" "encoded_irot3.avif" "encoded_irot3.png"
+  "${AVIFDEC}" "encoded_irot3.avif" -q 100 "encoded_irot3.jpg"
+
+  # Mirror (top-to-bottom)
+  "${AVIFENC}" --ignore-exif -s 10 "${INPUT_PNG}" --imir 0 -o "encoded_imir0.avif"
+  "${AVIFDEC}" "encoded_imir0.avif" "encoded_imir0.png"
+  "${AVIFDEC}" "encoded_imir0.avif" -q 100 "encoded_imir0.jpg"
+
+  # Mirror (left-to-right)
+  "${AVIFENC}" --ignore-exif -s 10 "${INPUT_PNG}" --imir 1 -o "encoded_imir1.avif"
+  "${AVIFDEC}" "encoded_imir1.avif" "encoded_imir1.png"
+  "${AVIFDEC}" "encoded_imir1.avif" -q 100 "encoded_imir1.jpg"
+
+  # Combined transforms: crop + mirror (no rotation)
+  "${AVIFENC}" --ignore-exif -s 10 "${INPUT_PNG}" --crop 10,50,100,25 --imir 0 -o "encoded_crop_imir0.avif"
+  "${AVIFDEC}" "encoded_crop_imir0.avif" "encoded_crop_imir0.png"
+  "${AVIFDEC}" "encoded_crop_imir0.avif" -q 100 "encoded_crop_imir0.jpg"
+
+  # Combined transforms: crop + rotation + mirror
+  # MIAF (ISO/IEC 23000-22) specifies the application order: 1. clap, 2. irot, 3. imir.
+  "${AVIFENC}" --ignore-exif -s 10 "${INPUT_PNG}" --crop 10,50,100,25 --irot 1 --imir 1 -o "encoded_combined.avif"
+  "${AVIFDEC}" "encoded_combined.avif" "encoded_combined.png"
+  "${AVIFDEC}" "encoded_combined.avif" -q 100 "encoded_combined.jpg"
 
   if command -v magick &> /dev/null
   then
@@ -57,9 +95,53 @@
   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
+  JPEG_PSNR_THRESHOLD=48
+
+  # Crop
+  "${IMAGEMAGICK}" "${ENCODED_FILE}.png" -crop 100x25+10+50 "encoded_clap_ref.png"
+  "${ARE_IMAGES_EQUAL}" "encoded_clap_ref.png" "encoded_clap.png" 0
+  "${ARE_IMAGES_EQUAL}" "encoded_clap_ref.png" "encoded_clap.jpg" 0 ${JPEG_PSNR_THRESHOLD}
+
+  # Rotation (0 degrees)
+  "${IMAGEMAGICK}" "${ENCODED_FILE}.png" -rotate 0 "encoded_irot0_ref.png"
+  "${ARE_IMAGES_EQUAL}" "encoded_irot0_ref.png" "encoded_irot0.png" 0
+  "${ARE_IMAGES_EQUAL}" "encoded_irot0_ref.png" "encoded_irot0.jpg" 0 ${JPEG_PSNR_THRESHOLD}
+
+  # Rotation (90 degrees anti-clockwise)
+  "${IMAGEMAGICK}" "${ENCODED_FILE}.png" -rotate -90 "encoded_irot1_ref.png"
+  "${ARE_IMAGES_EQUAL}" "encoded_irot1_ref.png" "encoded_irot1.png" 0
+  "${ARE_IMAGES_EQUAL}" "encoded_irot1_ref.png" "encoded_irot1.jpg" 0 ${JPEG_PSNR_THRESHOLD}
+
+  # Rotation (180 degrees)
+  "${IMAGEMAGICK}" "${ENCODED_FILE}.png" -rotate 180 "encoded_irot2_ref.png"
+  "${ARE_IMAGES_EQUAL}" "encoded_irot2_ref.png" "encoded_irot2.png" 0
+  "${ARE_IMAGES_EQUAL}" "encoded_irot2_ref.png" "encoded_irot2.jpg" 0 ${JPEG_PSNR_THRESHOLD}
+
+  # Rotation (90 clockwise)
+  "${IMAGEMAGICK}" "${ENCODED_FILE}.png" -rotate 90 "encoded_irot3_ref.png"
+  "${ARE_IMAGES_EQUAL}" "encoded_irot3_ref.png" "encoded_irot3.png" 0
+  "${ARE_IMAGES_EQUAL}" "encoded_irot3_ref.png" "encoded_irot3.jpg" 0 ${JPEG_PSNR_THRESHOLD}
+
+  # Mirror (top-to-bottom)
+  "${IMAGEMAGICK}" "${ENCODED_FILE}.png" -flip "encoded_imir0_ref.png"
+  "${ARE_IMAGES_EQUAL}" "encoded_imir0_ref.png" "encoded_imir0.png" 0
+  "${ARE_IMAGES_EQUAL}" "encoded_imir0_ref.png" "encoded_imir0.jpg" 0 ${JPEG_PSNR_THRESHOLD}
+
+  # Mirror (left-to-right)
+  "${IMAGEMAGICK}" "${ENCODED_FILE}.png" -flop "encoded_imir1_ref.png"
+  "${ARE_IMAGES_EQUAL}" "encoded_imir1_ref.png" "encoded_imir1.png" 0
+  "${ARE_IMAGES_EQUAL}" "encoded_imir1_ref.png" "encoded_imir1.jpg" 0 ${JPEG_PSNR_THRESHOLD}
+
+  # Combined transforms: crop + mirror (no rotation)
+  "${IMAGEMAGICK}" "${ENCODED_FILE}.png" -crop 100x25+10+50 -flip "encoded_crop_imir0_ref.png"
+  "${ARE_IMAGES_EQUAL}" "encoded_crop_imir0_ref.png" "encoded_crop_imir0.png" 0
+  "${ARE_IMAGES_EQUAL}" "encoded_crop_imir0_ref.png" "encoded_crop_imir0.jpg" 0 ${JPEG_PSNR_THRESHOLD}
+
+  # Combined transforms: crop + rotation + mirror
+  # MIAF (ISO/IEC 23000-22) specifies the application order: 1. clap, 2. irot, 3. imir.
+  "${IMAGEMAGICK}" "${ENCODED_FILE}.png" -crop 100x25+10+50 -rotate -90 -flop "encoded_combined_ref.png"
+  "${ARE_IMAGES_EQUAL}" "encoded_combined_ref.png" "encoded_combined.png" 0
+  "${ARE_IMAGES_EQUAL}" "encoded_combined_ref.png" "encoded_combined.jpg" 0 ${JPEG_PSNR_THRESHOLD}
 popd
 
 exit 0