Support encoding layered image with pre-scaled inputs

Add the width and height encoding options to avifEncoder.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d7c8307..206d2aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@
 
 * Add the ignoreICC option to avifDecoder
 * Support ignoring alpha in avifDecoder::imageContentToDecode
+* Support encoding layered image with pre-scaled inputs
 * avifenc: add --ignore-alpha flag to discard alpha channel on encode
 * avifgainmaputil: add --ignore-alpha flag to discard alpha channel
 * avifgainmaputil: add --ignore-exif and --ignore-xmp flags
diff --git a/apps/avifenc.c b/apps/avifenc.c
index e169839..c3b37d0 100644
--- a/apps/avifenc.c
+++ b/apps/avifenc.c
@@ -78,6 +78,10 @@
     avifMatrixCoefficients matrixCoefficients;
     avifChromaDownsampling chromaDownsampling;
     avifAppFileFormat inputFormat;
+
+    // The last layer's size. Inferred from the last input, only needed when using --layered.
+    uint32_t width;
+    uint32_t height;
 } avifSettings;
 
 typedef struct
@@ -454,6 +458,58 @@
     return AVIF_TRUE;
 }
 
+static avifBool avifVerifyImageFitsLastLayerSize(const avifSettings * settings, const avifImage * image, const char * filename)
+{
+    if (settings->width == 0) {
+        return AVIF_TRUE;
+    }
+    if ((image->width > settings->width) || (image->height > settings->height)) {
+        fprintf(stderr,
+                "ERROR: Input image dimensions [%ux%u] exceed the last layer's size [%ux%u]: %s\n",
+                image->width,
+                image->height,
+                settings->width,
+                settings->height,
+                filename);
+        return AVIF_FALSE;
+    }
+    return AVIF_TRUE;
+}
+
+// Checks, before encoding and in terms of CLI, for the settings that the library would reject once
+// combined with --layered inputs of different sizes (--layered sets avifEncoder.width/height, two
+// fields avifenc's users never set directly). Only checks conditions actually reachable via
+// avifenc's CLI. For example, grids and non-layered images can't reach this point at all, so they
+// are not checked here.
+static avifBool avifVerifyLastLayerSizeCompatibility(const avifSettings * settings, const avifInput * input, const avifImage * firstImage)
+{
+    if (settings->width == 0) {
+        return AVIF_TRUE;
+    }
+    for (int i = 0; i < settings->layers; ++i) {
+        const avifScalingMode * scalingMode = &input->files[i].settings.scalingMode.value;
+        const avifBool isNoScaling = (scalingMode->horizontal.n == scalingMode->horizontal.d) &&
+                                     (scalingMode->vertical.n == scalingMode->vertical.d);
+        if (input->files[i].settings.scalingMode.set && !isNoScaling) {
+            fprintf(stderr, "ERROR: --scaling-mode cannot be used with --layered inputs of different sizes\n");
+            return AVIF_FALSE;
+        }
+    }
+    if (input->requestedDepthExtension != 0) {
+        fprintf(stderr, "ERROR: --depth with bit depth extension cannot be used with --layered inputs of different sizes\n");
+        return AVIF_FALSE;
+    }
+#if defined(AVIF_ENABLE_JPEG_GAIN_MAP_CONVERSION)
+    if (firstImage->gainMap && firstImage->gainMap->image) {
+        fprintf(stderr, "ERROR: A gain map cannot be used with --layered inputs of different sizes (use --ignore-gain-map)\n");
+        return AVIF_FALSE;
+    }
+#else
+    (void)firstImage;
+#endif
+    return AVIF_TRUE;
+}
+
 static avifBool avifInputAddCachedImage(avifInput * input)
 {
     avifImage * newImage = avifImageCreateEmpty();
@@ -860,10 +916,11 @@
 static avifBool avifEncoderVerifyImageCompatibility(const avifImage * refImage,
                                                     const avifImage * testImage,
                                                     const char * seriesType,
-                                                    const char * filename)
+                                                    const char * filename,
+                                                    avifBool allowDimensionChange)
 {
     // Verify that this frame's properties matches the first frame's properties
-    if ((refImage->width != testImage->width) || (refImage->height != testImage->height)) {
+    if (!allowDimensionChange && ((refImage->width != testImage->width) || (refImage->height != testImage->height))) {
         fprintf(stderr,
                 "ERROR: Image %s dimensions mismatch, [%ux%u] vs [%ux%u]: %s\n",
                 seriesType,
@@ -954,7 +1011,11 @@
                                 settings->inputFormat)) {
             goto cleanup;
         }
-        if (!avifEncoderVerifyImageCompatibility(firstImage, nextImage, "sequence", avifPrettyFilename(nextFile->filename))) {
+        if (!avifEncoderVerifyImageCompatibility(firstImage,
+                                                 nextImage,
+                                                 "sequence",
+                                                 avifPrettyFilename(nextFile->filename),
+                                                 /*allowDimensionChange=*/AVIF_FALSE)) {
             goto cleanup;
         }
         if (!avifEncodeUpdateEncoderSettings(encoder, nextSettings)) {
@@ -1061,6 +1122,9 @@
                                     settings->inputFormat)) {
                 goto cleanup;
             }
+            if (!avifVerifyImageFitsLastLayerSize(settings, nextImage, avifPrettyFilename(nextFile->filename))) {
+                goto cleanup;
+            }
             // frameIter is NULL if y4m reached end, so single frame y4m is still supported.
             if (input->frameIter) {
                 fprintf(stderr,
@@ -1068,7 +1132,11 @@
                         avifPrettyFilename(nextFile->filename));
                 goto cleanup;
             }
-            if (!avifEncoderVerifyImageCompatibility(firstImage, nextImage, "layer", avifPrettyFilename(nextFile->filename))) {
+            if (!avifEncoderVerifyImageCompatibility(firstImage,
+                                                     nextImage,
+                                                     "layer",
+                                                     avifPrettyFilename(nextFile->filename),
+                                                     /*allowDimensionChange=*/AVIF_TRUE)) {
                 goto cleanup;
             }
             if (!avifEncodeUpdateEncoderSettings(encoder, nextSettings)) {
@@ -1129,6 +1197,8 @@
     encoder->creationTime = settings->creationTime;
     encoder->modificationTime = settings->modificationTime;
     encoder->extraLayerCount = settings->layers - 1;
+    encoder->width = settings->width;
+    encoder->height = settings->height;
     if (!avifEncodeUpdateEncoderSettings(encoder, &firstFile->settings)) {
         goto cleanup;
     }
@@ -2381,6 +2451,38 @@
         goto cleanup;
     }
 
+    uint32_t outputImageWidth = image->width;
+    uint32_t outputImageHeight = image->height;
+    if (settings.layered) {
+        // Get the resolution of the last layer without decoding it, to fill the output image size
+        // in advance.
+        // Only fill the output image size if the last layer's resolution differs from the first
+        // layer's, to not interfere with --scaling-mode.
+        const avifInputFile * lastFile = &input.files[input.filesCount - 1];
+        avifImage * lastImage = avifImageCreateEmpty();
+        if (!lastImage) {
+            fprintf(stderr, "ERROR: Out of memory\n");
+            goto cleanup;
+        }
+        const avifBool lastImageOk = avifPeekImage(lastFile->filename, settings.inputFormat, lastImage) != AVIF_APP_FILE_FORMAT_UNKNOWN;
+        if (lastImageOk && ((lastImage->width != image->width) || (lastImage->height != image->height))) {
+            outputImageWidth = settings.width = lastImage->width;
+            outputImageHeight = settings.height = lastImage->height;
+        }
+        avifImageDestroy(lastImage);
+        if (!lastImageOk) {
+            fprintf(stderr, "ERROR: Failed to peek last layer: %s\n", avifPrettyFilename(lastFile->filename));
+            goto cleanup;
+        }
+    }
+
+    if (!avifVerifyImageFitsLastLayerSize(&settings, image, avifPrettyFilename(firstFile->filename))) {
+        goto cleanup;
+    }
+    if (!avifVerifyLastLayerSizeCompatibility(&settings, &input, image)) {
+        goto cleanup;
+    }
+
     printf("Successfully loaded: %s\n", avifPrettyFilename(firstFile->filename));
 
     // Prepare image timings
@@ -2433,7 +2535,7 @@
         image->pasp.vSpacing = settings.paspValues[1];
     }
     if (cropConversionRequired) {
-        if (!convertCropToClap(image->width, image->height, settings.clapValues)) {
+        if (!convertCropToClap(outputImageWidth, outputImageHeight, settings.clapValues)) {
             goto cleanup;
         }
         settings.clapValid = AVIF_TRUE;
@@ -2453,7 +2555,7 @@
         avifCropRect cropRect;
         avifDiagnostics diag;
         avifDiagnosticsClearError(&diag);
-        if (!avifCropRectFromCleanApertureBox(&cropRect, &image->clap, image->width, image->height, &diag)) {
+        if (!avifCropRectFromCleanApertureBox(&cropRect, &image->clap, outputImageWidth, outputImageHeight, &diag)) {
             fprintf(stderr,
                     "ERROR: Invalid clap: width:[%d / %d], height:[%d / %d], horizOff:[%d / %d], vertOff:[%d / %d] - %s\n",
                     (int32_t)image->clap.widthN,
@@ -2634,8 +2736,14 @@
         lossyHint = " (Lossless)";
     }
     printf("AVIF to be written:%s\n", lossyHint);
+    // avifImageDump wants the info of one cell, but image, outputImageWidth, and outputImageHeight
+    // are the before-split image and its info when avifImageSplitGrid is called.
     const avifImage * avif = gridCells ? gridCells[0] : image;
+    const uint32_t cellWidth = gridCells ? avif->width : outputImageWidth;
+    const uint32_t cellHeight = gridCells ? avif->height : outputImageHeight;
     avifImageDump(avif,
+                  cellWidth,
+                  cellHeight,
                   settings.gridDims[0],
                   settings.gridDims[1],
                   settings.layers > 1 ? AVIF_PROGRESSIVE_STATE_AVAILABLE : AVIF_PROGRESSIVE_STATE_UNAVAILABLE);
diff --git a/apps/avifgainmaputil/imageio.cc b/apps/avifgainmaputil/imageio.cc
index 2333733..8b3ed6f 100644
--- a/apps/avifgainmaputil/imageio.cc
+++ b/apps/avifgainmaputil/imageio.cc
@@ -362,7 +362,7 @@
                      const std::string& output_filename) {
   avifRWData encoded = AVIF_DATA_EMPTY;
   std::cout << "AVIF to be written:\n";
-  avifImageDump(image,
+  avifImageDump(image, image->width, image->height,
                 /*gridCols=*/1,
                 /*gridRows=*/1, AVIF_PROGRESSIVE_STATE_UNAVAILABLE);
   PrintEncodingSettings(encoder, image->gainMap != nullptr);
@@ -407,7 +407,8 @@
 
   avifRWData encoded = AVIF_DATA_EMPTY;
   std::cout << "AVIF to be written:\n";
-  avifImageDump(grid_cells_ptrs[0], grid_cols, grid_rows,
+  avifImageDump(grid_cells_ptrs[0], grid_cells_ptrs[0]->width,
+                grid_cells_ptrs[0]->height, grid_cols, grid_rows,
                 AVIF_PROGRESSIVE_STATE_UNAVAILABLE);
   PrintEncodingSettings(encoder, image->gainMap != nullptr);
   avifResult result = avifEncoderAddImageGrid(encoder, grid_cols, grid_rows,
diff --git a/apps/shared/avifjpeg.c b/apps/shared/avifjpeg.c
index 0126cd2..cd9e061 100644
--- a/apps/shared/avifjpeg.c
+++ b/apps/shared/avifjpeg.c
@@ -486,7 +486,8 @@
                                      avifBool ignoreExif,
                                      avifBool ignoreXMP,
                                      avifBool ignoreGainMap,
-                                     uint32_t sizeLimit);
+                                     uint32_t sizeLimit,
+                                     avifBool headerOnly);
 
 // Arbitrary max number of jpeg segments to parse before giving up.
 #define MAX_JPEG_SEGMENTS 100
@@ -1024,7 +1025,8 @@
                                   /*ignoreExif=*/AVIF_TRUE,
                                   /*ignoreXMP=*/AVIF_FALSE,
                                   /*ignoreGainMap=*/AVIF_TRUE,
-                                  sizeLimit)) {
+                                  sizeLimit,
+                                  /*headerOnly=*/AVIF_FALSE)) {
             continue;
         }
         if (avifJPEGHasGainMapXMPNode(avif->xmp.data, avif->xmp.size, NULL)) {
@@ -1261,7 +1263,8 @@
                                      avifBool ignoreExif,
                                      avifBool ignoreXMP,
                                      avifBool ignoreGainMap,
-                                     uint32_t sizeLimit)
+                                     uint32_t sizeLimit,
+                                     avifBool headerOnly)
 {
     volatile avifBool ret = AVIF_FALSE;
     uint8_t * volatile iccData = NULL;
@@ -1305,6 +1308,8 @@
         fprintf(stderr, "Too big JPEG dimensions (%u x %u > %u px): %s\n", cinfo.output_width, cinfo.output_height, sizeLimit, inputFilename);
         goto cleanup;
     }
+    avif->width = cinfo.output_width;
+    avif->height = cinfo.output_height;
 
     if (!ignoreColorProfile) {
         uint8_t * iccDataTmp;
@@ -1336,6 +1341,12 @@
     // JPEG doesn't have alpha. Prevent confusion.
     avif->alphaPremultiplied = AVIF_FALSE;
 
+    if (headerOnly) {
+        // No real decoding needed. Stop here.
+        ret = AVIF_TRUE;
+        goto cleanup;
+    }
+
     if (avifJPEGReadCopy(avif, sizeLimit, &cinfo)) {
         // JPEG pixels were successfully copied without conversion. Notify the enduser.
 
@@ -1351,8 +1362,6 @@
         int row_stride = cinfo.output_width * cinfo.output_components;
         JSAMPARRAY buffer = (*cinfo.mem->alloc_sarray)((j_common_ptr)&cinfo, JPOOL_IMAGE, row_stride, 1);
 
-        avif->width = cinfo.output_width;
-        avif->height = cinfo.output_height;
         if (avif->matrixCoefficients == AVIF_MATRIX_COEFFICIENTS_YCGCO_RO) {
             fprintf(stderr, "AVIF_MATRIX_COEFFICIENTS_YCGCO_RO cannot be used with JPEG because it has an even bit depth.\n");
             goto cleanup;
@@ -1672,13 +1681,37 @@
                                               ignoreExif,
                                               ignoreXMP,
                                               ignoreGainMap,
-                                              sizeLimit);
+                                              sizeLimit,
+                                              /*headerOnly=*/AVIF_FALSE);
     if (f && f != stdin) {
         fclose(f);
     }
     return res;
 }
 
+avifBool avifJPEGPeek(const char * inputFilename, avifImage * avif)
+{
+    FILE * f = fopen(inputFilename, "rb");
+    if (!f) {
+        fprintf(stderr, "Can't open JPEG file for read: %s\n", inputFilename);
+        return AVIF_FALSE;
+    }
+    const avifBool res = avifJPEGReadInternal(f,
+                                              inputFilename,
+                                              avif,
+                                              AVIF_PIXEL_FORMAT_NONE,
+                                              /*requestedDepth=*/0,
+                                              AVIF_CHROMA_DOWNSAMPLING_AUTOMATIC,
+                                              /*ignoreColorProfile=*/AVIF_TRUE,
+                                              /*ignoreExif=*/AVIF_TRUE,
+                                              /*ignoreXMP=*/AVIF_TRUE,
+                                              /*ignoreGainMap=*/AVIF_TRUE,
+                                              /*sizeLimit=*/UINT32_MAX,
+                                              /*headerOnly=*/AVIF_TRUE);
+    fclose(f);
+    return res;
+}
+
 avifBool avifJPEGWrite(const char * outputFilename, const avifImage * avif, int jpegQuality, avifChromaUpsampling chromaUpsampling)
 {
     avifBool ret = AVIF_FALSE;
diff --git a/apps/shared/avifjpeg.h b/apps/shared/avifjpeg.h
index 4cfc1e5..ad035d7 100644
--- a/apps/shared/avifjpeg.h
+++ b/apps/shared/avifjpeg.h
@@ -26,6 +26,11 @@
                       avifBool ignoreXMP,
                       avifBool ignoreGainMap,
                       uint32_t sizeLimit);
+
+// Parse the jpeg file at path 'inputFilename' and write its metadata into 'avif'
+// without decoding the pixels.
+avifBool avifJPEGPeek(const char * inputFilename, avifImage * avif);
+
 avifBool avifJPEGWrite(const char * outputFilename, const avifImage * avif, int jpegQuality, avifChromaUpsampling chromaUpsampling);
 
 #if defined(AVIF_ENABLE_JPEG_GAIN_MAP_CONVERSION)
diff --git a/apps/shared/avifpng.c b/apps/shared/avifpng.c
index 9d7fee5..cfb5049 100644
--- a/apps/shared/avifpng.c
+++ b/apps/shared/avifpng.c
@@ -279,7 +279,8 @@
                                 avifBool ignoreXMP,
                                 avifBool ignoreAlpha,
                                 uint32_t imageSizeLimit,
-                                uint32_t * outPNGDepth)
+                                uint32_t * outPNGDepth,
+                                avifBool headerOnly)
 {
     volatile avifBool readResult = AVIF_FALSE;
     png_structp png = NULL;
@@ -505,15 +506,23 @@
         // Note: There is no support for the rare "Raw profile type icc" or "Raw profile type icm" text chunks.
     }
 
+    if (avif->width > imageSizeLimit / avif->height) {
+        fprintf(stderr, "Too big PNG dimensions (%u x %u > %u px): %s\n", avif->width, avif->height, imageSizeLimit, inputFilename);
+        goto cleanup;
+    }
+
+    if (headerOnly) {
+        // All the metadata read so far (dimensions, depth, yuvFormat, ICC/color primaries if
+        // requested) is already set on avif. Stop here instead of decoding any pixel data.
+        readResult = AVIF_TRUE;
+        goto cleanup;
+    }
+
     const int numChannels = png_get_channels(png, info);
     if (numChannels < 1 || numChannels > 4) {
         fprintf(stderr, "png_get_channels() should return 1, 2, 3 or 4 but returns %d.\n", numChannels);
         goto cleanup;
     }
-    if (avif->width > imageSizeLimit / avif->height) {
-        fprintf(stderr, "Too big PNG dimensions (%u x %u > %u px): %s\n", avif->width, avif->height, imageSizeLimit, inputFilename);
-        goto cleanup;
-    }
 
     avifRGBImageSetDefaults(&rgb, avif);
     rgb.chromaDownsampling = chromaDownsampling;
@@ -613,7 +622,8 @@
                                          ignoreXMP,
                                          ignoreAlpha,
                                          imageSizeLimit,
-                                         outPNGDepth);
+                                         outPNGDepth,
+                                         /*headerOnly=*/AVIF_FALSE);
 
     if (f != stdin) {
         fclose(f);
@@ -621,6 +631,31 @@
     return res;
 }
 
+avifBool avifPNGPeek(const char * inputFilename, avifImage * avif)
+{
+    FILE * f = fopen(inputFilename, "rb");
+    if (!f) {
+        fprintf(stderr, "Can't open PNG file for read: %s\n", inputFilename);
+        return AVIF_FALSE;
+    }
+
+    const avifBool res = avifPNGReadImpl(f,
+                                         inputFilename,
+                                         avif,
+                                         /*requestedFormat=*/AVIF_PIXEL_FORMAT_NONE,
+                                         /*requestedDepth=*/0,
+                                         AVIF_CHROMA_DOWNSAMPLING_AUTOMATIC,
+                                         /*ignoreColorProfile=*/AVIF_TRUE,
+                                         /*ignoreExif=*/AVIF_TRUE,
+                                         /*ignoreXMP=*/AVIF_TRUE,
+                                         /*ignoreAlpha=*/AVIF_TRUE,
+                                         /*imageSizeLimit=*/UINT32_MAX,
+                                         /*outPNGDepth=*/NULL,
+                                         /*headerOnly=*/AVIF_TRUE);
+    fclose(f);
+    return res;
+}
+
 //------------------------------------------------------------------------------
 // Writing
 
diff --git a/apps/shared/avifpng.h b/apps/shared/avifpng.h
index b50d2b7..cbe3774 100644
--- a/apps/shared/avifpng.h
+++ b/apps/shared/avifpng.h
@@ -22,6 +22,11 @@
                      avifBool ignoreAlpha,
                      uint32_t imageSizeLimit,
                      uint32_t * outPNGDepth);
+
+// Parse the PNG file at path 'inputFilename' and write its metadata into 'avif'
+// without decoding the pixels.
+avifBool avifPNGPeek(const char * inputFilename, avifImage * avif);
+
 avifBool avifPNGWrite(const char * outputFilename,
                       const avifImage * avif,
                       uint32_t requestedDepth,
diff --git a/apps/shared/avifutil.c b/apps/shared/avifutil.c
index bd0eaf6..6a370fc 100644
--- a/apps/shared/avifutil.c
+++ b/apps/shared/avifutil.c
@@ -61,10 +61,16 @@
     }
 }
 
-static void avifImageDumpInternal(const avifImage * avif, uint32_t gridCols, uint32_t gridRows, avifBool alphaPresent, avifProgressiveState progressiveState)
+static void avifImageDumpInternal(const avifImage * avif,
+                                  uint32_t cellWidth,
+                                  uint32_t cellHeight,
+                                  uint32_t gridCols,
+                                  uint32_t gridRows,
+                                  avifBool alphaPresent,
+                                  avifProgressiveState progressiveState)
 {
-    uint32_t width = avif->width;
-    uint32_t height = avif->height;
+    uint32_t width = cellWidth;
+    uint32_t height = cellHeight;
     if (gridCols && gridRows) {
         width *= gridCols;
         height *= gridRows;
@@ -120,7 +126,7 @@
             avifCropRect cropRect;
             avifDiagnostics diag;
             avifDiagnosticsClearError(&diag);
-            avifBool validClap = avifCropRectFromCleanApertureBox(&cropRect, &avif->clap, avif->width, avif->height, &diag);
+            avifBool validClap = avifCropRectFromCleanApertureBox(&cropRect, &avif->clap, cellWidth, cellHeight, &diag);
             if (validClap) {
                 printf("      * Valid, derived crop rect: X: %d, Y: %d, W: %d, H: %d%s\n",
                        cropRect.x,
@@ -193,15 +199,15 @@
     }
 }
 
-void avifImageDump(const avifImage * avif, uint32_t gridCols, uint32_t gridRows, avifProgressiveState progressiveState)
+void avifImageDump(const avifImage * avif, uint32_t cellWidth, uint32_t cellHeight, uint32_t gridCols, uint32_t gridRows, avifProgressiveState progressiveState)
 {
     const avifBool alphaPresent = avif->alphaPlane && (avif->alphaRowBytes > 0);
-    avifImageDumpInternal(avif, gridCols, gridRows, alphaPresent, progressiveState);
+    avifImageDumpInternal(avif, cellWidth, cellHeight, gridCols, gridRows, alphaPresent, progressiveState);
 }
 
 void avifContainerDump(const avifDecoder * decoder)
 {
-    avifImageDumpInternal(decoder->image, 0, 0, decoder->alphaPresent, decoder->progressiveState);
+    avifImageDumpInternal(decoder->image, decoder->image->width, decoder->image->height, 0, 0, decoder->alphaPresent, decoder->progressiveState);
     if (decoder->imageSequenceTrackPresent) {
         if (decoder->repetitionCount == AVIF_REPETITION_COUNT_INFINITE) {
             printf(" * Repeat Count   : Infinite\n");
@@ -364,6 +370,34 @@
     return inputFormat;
 }
 
+avifAppFileFormat avifPeekImage(const char * filename, avifAppFileFormat inputFormat, avifImage * image)
+{
+    if (inputFormat == AVIF_APP_FILE_FORMAT_UNKNOWN) {
+        inputFormat = avifGuessFileFormat(filename);
+    }
+
+    if (inputFormat == AVIF_APP_FILE_FORMAT_Y4M) {
+        if (!y4mPeek(filename, image)) {
+            return AVIF_APP_FILE_FORMAT_UNKNOWN;
+        }
+    } else if (inputFormat == AVIF_APP_FILE_FORMAT_JPEG) {
+        if (!avifJPEGPeek(filename, image)) {
+            return AVIF_APP_FILE_FORMAT_UNKNOWN;
+        }
+    } else if (inputFormat == AVIF_APP_FILE_FORMAT_PNG) {
+        if (!avifPNGPeek(filename, image)) {
+            return AVIF_APP_FILE_FORMAT_UNKNOWN;
+        }
+    } else if (inputFormat == AVIF_APP_FILE_FORMAT_UNKNOWN) {
+        fprintf(stderr, "Unrecognized file format for input file: %s\n", filename);
+        return AVIF_APP_FILE_FORMAT_UNKNOWN;
+    } else {
+        fprintf(stderr, "Unsupported file format %s for input file: %s\n", avifFileFormatToString(inputFormat), filename);
+        return AVIF_APP_FILE_FORMAT_UNKNOWN;
+    }
+    return inputFormat;
+}
+
 avifBool avifReadEntireFile(const char * filename, avifRWData * raw)
 {
     FILE * f = fopen(filename, "rb");
diff --git a/apps/shared/avifutil.h b/apps/shared/avifutil.h
index 5b01d85..bec09ab 100644
--- a/apps/shared/avifutil.h
+++ b/apps/shared/avifutil.h
@@ -28,7 +28,9 @@
 #define AVIF_FMT_ZU "zu"
 #endif
 
-void avifImageDump(const avifImage * avif, uint32_t gridCols, uint32_t gridRows, avifProgressiveState progressiveState);
+// Dumps the information of an AVIF file that has gridCols*gridRows cells (1*1 for non-grid image)
+// and 'avif' as the first cell that's encoded to have size cellWidth*cellHeight.
+void avifImageDump(const avifImage * avif, uint32_t cellWidth, uint32_t cellHeight, uint32_t gridCols, uint32_t gridRows, avifProgressiveState progressiveState);
 void avifContainerDump(const avifDecoder * decoder);
 void avifPrintVersions(void);
 void avifDumpDiagnostics(const avifDiagnostics * diag);
@@ -118,6 +120,16 @@
                                 avifAppSourceTiming * sourceTiming,
                                 struct y4mFrameIterator ** frameIter);
 
+// Determines only the metadata available before pixel decoding starts (currently width, height,
+// depth, yuvFormat and, for JPEG/PNG, color profile information) for an image file, without
+// decoding any pixel data.
+// If 'inputFormat' is AVIF_APP_FILE_FORMAT_UNKNOWN, the image format is guessed
+// based on the filename or first few bytes.
+// Returns the format of the file, or AVIF_APP_FILE_FORMAT_UNKNOWN in case of error.
+// Note: This function reuses the code that reads an image. The current user of this function only
+// uses the width and height reported. Double check first when using the other info returned.
+avifAppFileFormat avifPeekImage(const char * filename, avifAppFileFormat inputFormat, avifImage * image);
+
 // Copies all the bytes from the file at filename to a newly allocated memory chunk.
 avifBool avifReadEntireFile(const char * filename, avifRWData * raw);
 
diff --git a/apps/shared/y4m.c b/apps/shared/y4m.c
index 9a604ed..791aa82 100644
--- a/apps/shared/y4m.c
+++ b/apps/shared/y4m.c
@@ -253,12 +253,13 @@
             goto cleanup; \
     } while (0)
 
-avifBool y4mRead(const char * inputFilename,
-                 avifBool ignoreAlpha,
-                 uint32_t imageSizeLimit,
-                 avifImage * avif,
-                 avifAppSourceTiming * sourceTiming,
-                 struct y4mFrameIterator ** iter)
+static avifBool y4mReadInternal(const char * inputFilename,
+                                avifBool ignoreAlpha,
+                                uint32_t imageSizeLimit,
+                                avifBool headerOnly,
+                                avifImage * avif,
+                                avifAppSourceTiming * sourceTiming,
+                                struct y4mFrameIterator ** iter)
 {
     avifBool result = AVIF_FALSE;
 
@@ -412,6 +413,14 @@
     avif->yuvFormat = frame.format;
     avif->yuvRange = frame.range;
     avif->yuvChromaSamplePosition = frame.chromaSamplePosition;
+
+    if (headerOnly) {
+        // All the metadata (dimensions, depth, format, range, chroma sample position) is already
+        // known. Stop here instead of reading any frame data.
+        result = AVIF_TRUE;
+        goto cleanup;
+    }
+
     avifResult allocationResult = avifImageAllocatePlanes(avif, frame.hasAlpha ? AVIF_PLANES_ALL : AVIF_PLANES_YUV);
     if (allocationResult != AVIF_RESULT_OK) {
         fprintf(stderr, "Failed to allocate the planes: %s\n", avifResultToString(allocationResult));
@@ -478,6 +487,27 @@
     return result;
 }
 
+avifBool y4mRead(const char * inputFilename,
+                 avifBool ignoreAlpha,
+                 uint32_t imageSizeLimit,
+                 avifImage * avif,
+                 avifAppSourceTiming * sourceTiming,
+                 struct y4mFrameIterator ** iter)
+{
+    return y4mReadInternal(inputFilename, ignoreAlpha, imageSizeLimit, /*headerOnly=*/AVIF_FALSE, avif, sourceTiming, iter);
+}
+
+avifBool y4mPeek(const char * inputFilename, avifImage * avif)
+{
+    return y4mReadInternal(inputFilename,
+                           /*ignoreAlpha=*/AVIF_TRUE,
+                           /*imageSizeLimit=*/UINT32_MAX,
+                           /*headerOnly=*/AVIF_TRUE,
+                           avif,
+                           /*sourceTiming=*/NULL,
+                           /*iter=*/NULL);
+}
+
 avifBool y4mWrite(const char * outputFilename, const avifImage * avif)
 {
     avifBool hasAlpha = (avif->alphaPlane != NULL) && (avif->alphaRowBytes > 0);
diff --git a/apps/shared/y4m.h b/apps/shared/y4m.h
index bf9d645..4b4630e 100644
--- a/apps/shared/y4m.h
+++ b/apps/shared/y4m.h
@@ -24,6 +24,11 @@
                  avifImage * avif,
                  avifAppSourceTiming * sourceTiming,
                  struct y4mFrameIterator ** iter);
+
+// Parse the y4m file at path 'inputFilename' and write its metadata into 'avif'
+// without decoding the pixels.
+avifBool y4mPeek(const char * inputFilename, avifImage * avif);
+
 avifBool y4mWrite(const char * outputFilename, const avifImage * avif);
 
 #ifdef __cplusplus
diff --git a/include/avif/avif.h b/include/avif/avif.h
index bd81044..92ea161 100644
--- a/include/avif/avif.h
+++ b/include/avif/avif.h
@@ -821,6 +821,12 @@
     // To encode any of these boxes, set the values in the associated box, then enable the flag in
     // transformFlags. On decode, only honor the values in boxes with the associated transform flag set.
     // These also apply to gainMap->image, if any.
+    //
+    // When encoding with avifEncoder.width/height set (see their comment), these transformations
+    // are interpreted relative to that size rather than to the current layer's own
+    // width/height. No special handling is needed during decode: the decoded layer is
+    // automatically scaled to the configured size, so these transformations are relative to the
+    // decoded avifImage's width/height as usual.
     avifTransformFlags transformFlags;
     avifPixelAspectRatioBox pasp;
     avifCleanApertureBox clap;
@@ -1582,6 +1588,7 @@
     avifBool autoTiling;
 
     // Up/down scaling of the image to perform before encoding.
+    // This cannot be used together with encoder->width / encoder->height.
     avifScalingMode scalingMode;
 
     // --------------------------------------------------------------------------------------------
@@ -1628,6 +1635,13 @@
 
     // Version 1.4.0 ends here. Add any new members after this line.
     // --------------------------------------------------------------------------------------------
+
+    // Only for layered image (extraLayerCount > 0), otherwise must be the default value 0.
+    // Declares the size of the encoded image beforehand, which shall be exactly the size of the
+    // last layer. This allows adding smaller images as earlier layers to avoid a wasted scaling
+    // round trip, or scale ratios that the encoder does not support via scalingMode.
+    uint32_t width;
+    uint32_t height;
 } avifEncoder;
 
 // Creates an encoder initialized with default settings values.
diff --git a/src/codec_aom.c b/src/codec_aom.c
index ec4a50a..dd6205a 100644
--- a/src/codec_aom.c
+++ b/src/codec_aom.c
@@ -871,6 +871,8 @@
         cfg->g_input_bit_depth = image->depth;
         cfg->g_w = image->width;
         cfg->g_h = image->height;
+        cfg->g_forced_max_frame_width = encoder->width;
+        cfg->g_forced_max_frame_height = encoder->height;
 
         // Detect the libaom v3.6.0 bug described in
         // https://crbug.com/aomedia/2871#c12. See the changes to
@@ -933,10 +935,30 @@
         if (disableLaggedOutput) {
             cfg->g_lag_in_frames = 0;
         }
+        if ((encoder->width || encoder->height) && (cfg->g_lag_in_frames > 1)) {
+            // aom_codec_enc_config_set() does not allow changing frame dimensions if
+            // g_lag_in_frames > 1.
+            cfg->g_lag_in_frames = 1;
+        }
         if (encoder->maxThreads > 1) {
             // libaom fails if cfg->g_threads is greater than 64 threads. See MAX_NUM_THREADS in
             // aom/aom_util/aom_thread.h.
             cfg->g_threads = AVIF_MIN(encoder->maxThreads, 64);
+
+            // Detect the libaom bug before v3.15.2 described in
+            // https://issues.oss-fuzz.com/issues/559019046. See the changes in
+            // https://aomedia-review.googlesource.com/c/aom/+/216921.
+            static const int aomVersion_3_15_2 = (3 << 16) | (15 << 8) | 2;
+            if (aom_codec_version() < aomVersion_3_15_2) {
+                // When creating extra worker threads during encoding (which can
+                // happen when the new frame is larger), libaom may skip
+                // allocating the pixel_gradient_info buffers needed by
+                // GOOD_QUALITY mode. Work around the bug by disabling
+                // multithreading.
+                if (aomUsage == AOM_USAGE_GOOD_QUALITY && (encoder->width || encoder->height)) {
+                    cfg->g_threads = 1;
+                }
+            }
         }
 
         // Encode alpha as 4:0:0.
@@ -1082,8 +1104,9 @@
     } else {
         avifBool dimensionsChanged = AVIF_FALSE;
         if ((cfg->g_w != image->width) || (cfg->g_h != image->height)) {
-            // We are not ready for dimension change for now.
-            return AVIF_RESULT_NOT_IMPLEMENTED;
+            cfg->g_w = image->width;
+            cfg->g_h = image->height;
+            dimensionsChanged = AVIF_TRUE;
         }
         if (alpha) {
             if (encoderChanges & (AVIF_ENCODER_CHANGE_MIN_QUANTIZER_ALPHA | AVIF_ENCODER_CHANGE_MAX_QUANTIZER_ALPHA)) {
diff --git a/src/codec_avm.c b/src/codec_avm.c
index 6222f7f..77b795a 100644
--- a/src/codec_avm.c
+++ b/src/codec_avm.c
@@ -444,6 +444,11 @@
                                       avifAddImageFlags addImageFlags,
                                       avifCodecEncodeOutput * output)
 {
+    // AVM does not support encoding a layered image.
+    if (encoder->width || encoder->height) {
+        return AVIF_RESULT_NOT_IMPLEMENTED;
+    }
+
     struct avm_codec_enc_cfg * cfg = &codec->internal->cfg;
     avifBool quantizerUpdated = AVIF_FALSE;
     const int quantizer = avmQualityToQuantizer(quality, image->depth);
diff --git a/src/codec_rav1e.c b/src/codec_rav1e.c
index 97c92be..7fc064e 100644
--- a/src/codec_rav1e.c
+++ b/src/codec_rav1e.c
@@ -82,8 +82,8 @@
         return AVIF_RESULT_NOT_IMPLEMENTED;
     }
 
-    // rav1e does not support encoding layered image.
-    if (encoder->extraLayerCount > 0) {
+    // rav1e does not support encoding a layered image.
+    if (encoder->width || encoder->height || encoder->extraLayerCount > 0) {
         return AVIF_RESULT_NOT_IMPLEMENTED;
     }
 
diff --git a/src/codec_svt.c b/src/codec_svt.c
index a76fb5b..395afc8 100644
--- a/src/codec_svt.c
+++ b/src/codec_svt.c
@@ -75,8 +75,8 @@
         }
     }
 
-    // SVT-AV1 does not support encoding layered image.
-    if (encoder->extraLayerCount > 0) {
+    // SVT-AV1 does not support encoding a layered image.
+    if (encoder->width || encoder->height || encoder->extraLayerCount > 0) {
         return AVIF_RESULT_NOT_IMPLEMENTED;
     }
 
diff --git a/src/write.c b/src/write.c
index 2070600..f6653d1 100644
--- a/src/write.c
+++ b/src/write.c
@@ -477,6 +477,45 @@
 
 static const avifScalingMode noScaling = { { 1, 1 }, { 1, 1 } };
 
+static avifBool avifEncoderSizeIsSet(const avifEncoder * encoder)
+{
+    return (encoder->width != 0) || (encoder->height != 0);
+}
+
+static avifBool avifScalingModeIsNoScaling(const avifScalingMode * scalingMode)
+{
+    return (scalingMode->horizontal.n == scalingMode->horizontal.d) && (scalingMode->vertical.n == scalingMode->vertical.d);
+}
+
+static avifBool avifImageHasEquivalentTransformProperties(const avifImage * lhs, const avifImage * rhs)
+{
+    const uint32_t lhsTransformFlags = lhs->transformFlags &
+                                       (AVIF_TRANSFORM_PASP | AVIF_TRANSFORM_CLAP | AVIF_TRANSFORM_IROT | AVIF_TRANSFORM_IMIR);
+    const uint32_t rhsTransformFlags = rhs->transformFlags &
+                                       (AVIF_TRANSFORM_PASP | AVIF_TRANSFORM_CLAP | AVIF_TRANSFORM_IROT | AVIF_TRANSFORM_IMIR);
+    if (lhsTransformFlags != rhsTransformFlags) {
+        return AVIF_FALSE;
+    }
+    if ((lhsTransformFlags & AVIF_TRANSFORM_PASP) &&
+        ((lhs->pasp.hSpacing != rhs->pasp.hSpacing) || (lhs->pasp.vSpacing != rhs->pasp.vSpacing))) {
+        return AVIF_FALSE;
+    }
+    if ((lhsTransformFlags & AVIF_TRANSFORM_CLAP) &&
+        ((lhs->clap.widthN != rhs->clap.widthN) || (lhs->clap.widthD != rhs->clap.widthD) ||
+         (lhs->clap.heightN != rhs->clap.heightN) || (lhs->clap.heightD != rhs->clap.heightD) ||
+         (lhs->clap.horizOffN != rhs->clap.horizOffN) || (lhs->clap.horizOffD != rhs->clap.horizOffD) ||
+         (lhs->clap.vertOffN != rhs->clap.vertOffN) || (lhs->clap.vertOffD != rhs->clap.vertOffD))) {
+        return AVIF_FALSE;
+    }
+    if ((lhsTransformFlags & AVIF_TRANSFORM_IROT) && (lhs->irot.angle != rhs->irot.angle)) {
+        return AVIF_FALSE;
+    }
+    if ((lhsTransformFlags & AVIF_TRANSFORM_IMIR) && (lhs->imir.axis != rhs->imir.axis)) {
+        return AVIF_FALSE;
+    }
+    return AVIF_TRUE;
+}
+
 avifEncoder * avifEncoderCreate(void)
 {
     avifEncoder * encoder = (avifEncoder *)avifAlloc(sizeof(avifEncoder));
@@ -511,6 +550,8 @@
     encoder->creationTime = 0;
     encoder->modificationTime = 0;
     encoder->sampleTransformRecipe = AVIF_SAMPLE_TRANSFORM_NONE;
+    encoder->width = 0;
+    encoder->height = 0;
     return encoder;
 }
 
@@ -554,6 +595,8 @@
     encoder->data->lastTileColsLog2 = encoder->data->tileColsLog2;
     lastEncoder->scalingMode = encoder->scalingMode;
     lastEncoder->sampleTransformRecipe = encoder->sampleTransformRecipe;
+    lastEncoder->width = encoder->width;
+    lastEncoder->height = encoder->height;
 }
 
 // This function detects changes made on avifEncoder. It returns true on success (i.e., if every
@@ -572,7 +615,8 @@
     if ((lastEncoder->codecChoice != encoder->codecChoice) || (lastEncoder->maxThreads != encoder->maxThreads) ||
         (lastEncoder->speed != encoder->speed) || (lastEncoder->keyframeInterval != encoder->keyframeInterval) ||
         (lastEncoder->timescale != encoder->timescale) || (lastEncoder->repetitionCount != encoder->repetitionCount) ||
-        (lastEncoder->extraLayerCount != encoder->extraLayerCount)) {
+        (lastEncoder->extraLayerCount != encoder->extraLayerCount) || (lastEncoder->width != encoder->width) ||
+        (lastEncoder->height != encoder->height)) {
         return AVIF_FALSE;
     }
 
@@ -1587,6 +1631,129 @@
     return avifCodecTypeFromChoice(encoder->codecChoice, AVIF_CODEC_FLAG_CAN_ENCODE);
 }
 
+static avifResult avifEncoderValidateSize(avifEncoder * encoder, uint32_t gridCols, uint32_t gridRows, const avifImage * firstCell)
+{
+    if (!avifEncoderSizeIsSet(encoder)) {
+        // First image, encoder->data->imageMetadata not initialized yet, nothing to check against.
+        if (encoder->data->items.count == 0) {
+            return AVIF_RESULT_OK;
+        }
+
+        // For animation, the encoder verifies the input size never changes, so this check is redundant.
+        // But for a layered image, the encoder relaxes the check to allow layers to have different sizes.
+        // We support 2 ways to produce different sized layers:
+        // 1. Send full sized inputs and let the encoder scale them via scalingMode
+        // 2. Declare the full size via width/height and send pre-scaled inputs
+        // To avoid confusion, we require the user to consistently use only one of the 2 ways,
+        // and this check blocks the attempt to start with method 1 and try to switch to method 2.
+        if ((firstCell->width != encoder->data->imageMetadata->width) || (firstCell->height != encoder->data->imageMetadata->height)) {
+            avifDiagnosticsPrintf(&encoder->diag, "All images must have the same width/height unless avifEncoder.width/height is set");
+            return AVIF_RESULT_INVALID_ARGUMENT;
+        }
+
+        // According to section 2.2.2 of AV1 Image File Format specification v1.2.0:
+        //   [...] the values of image_width and image_height shall respectively equal the values of
+        //   UpscaledWidth and FrameHeight as defined in [AV1] but for a specific frame in the item
+        //   payload. [...]
+        //   In the absence of a 'lsel' property associated with the item, or if it is present and its
+        //   layer_id value is set to 0xFFFF:
+        //     If no OperatingPointSelectorProperty is associated with the item, the 'ispe' property
+        //     shall document the dimensions of the last frame decoded when processing the operating
+        //     point whose index is 0.
+        //   NOTE: The dimensions of possible intermediate output images might not match the ones given
+        //   in the 'ispe' property. If renderers display these intermediate images, they are expected
+        //   to scale the output image to match the 'ispe' property.
+        // See https://aomediacodec.github.io/av1-avif/v1.2.0.html#image-spatial-extents-property.
+        //
+        // Therefore the last layer must not have any scaling.
+        if ((encoder->data->frames.count == encoder->extraLayerCount) && !avifScalingModeIsNoScaling(&encoder->scalingMode)) {
+            avifDiagnosticsPrintf(&encoder->diag,
+                                  "The last layer must not be scaled, but got scalingMode=(%d/%d, %d/%d)",
+                                  encoder->scalingMode.horizontal.n,
+                                  encoder->scalingMode.horizontal.d,
+                                  encoder->scalingMode.vertical.n,
+                                  encoder->scalingMode.vertical.d);
+            return AVIF_RESULT_INVALID_ARGUMENT;
+        }
+        return AVIF_RESULT_OK;
+    }
+
+    if ((encoder->width == 0) || (encoder->height == 0)) {
+        avifDiagnosticsPrintf(&encoder->diag, "avifEncoder.width and avifEncoder.height must either both be zero or both be nonzero");
+        return AVIF_RESULT_INVALID_ARGUMENT;
+    }
+
+    if ((gridCols > 1) || (gridRows > 1)) {
+        avifDiagnosticsPrintf(&encoder->diag, "avifEncoder.width/height cannot be set with grid images");
+        return AVIF_RESULT_NOT_IMPLEMENTED;
+    }
+
+    // This blocks the attempt to use both ways to produce different sized layers together,
+    // or start with method 2 and try to switch to method 1,
+    // so the result is INVALID_ARGUMENT. See the comment above for the detail.
+    if (!avifScalingModeIsNoScaling(&encoder->scalingMode)) {
+        avifDiagnosticsPrintf(&encoder->diag, "avifEncoder.width/height cannot be set together with encoder->scalingMode");
+        return AVIF_RESULT_INVALID_ARGUMENT;
+    }
+
+    if (encoder->sampleTransformRecipe != AVIF_SAMPLE_TRANSFORM_NONE) {
+        avifDiagnosticsPrintf(&encoder->diag, "avifEncoder.width/height cannot be set with sample transforms");
+        return AVIF_RESULT_NOT_IMPLEMENTED;
+    }
+
+    if (firstCell->gainMap && firstCell->gainMap->image) {
+        avifDiagnosticsPrintf(&encoder->diag, "avifEncoder.width/height cannot be set with gain maps");
+        return AVIF_RESULT_NOT_IMPLEMENTED;
+    }
+
+    // These 3 checks below are based on section 2.2.2 of AV1 Image File Format specification v1.2.0.
+    // See the comment above for the detail.
+    // Only a layered image can have frames of different sizes, so reject otherwise.
+    if (encoder->extraLayerCount == 0) {
+        avifDiagnosticsPrintf(&encoder->diag, "avifEncoder.width/height can only be set for layered images (extraLayerCount > 0)");
+        return AVIF_RESULT_INVALID_ARGUMENT;
+    }
+
+    // The spec allows arbitrary sizes. We tighten the rule to "earlier layers shall be smaller" due to encoder restrictions.
+    if ((encoder->width < firstCell->width) || (encoder->height < firstCell->height)) {
+        avifDiagnosticsPrintf(&encoder->diag,
+                              "avifEncoder.width/height %ux%u must be at least the coded image size %ux%u",
+                              encoder->width,
+                              encoder->height,
+                              firstCell->width,
+                              firstCell->height);
+        return AVIF_RESULT_INCOMPATIBLE_IMAGE;
+    }
+
+    // The declared width/height must exactly match the coded size of the last layer.
+    if ((encoder->data->frames.count == encoder->extraLayerCount) &&
+        ((encoder->width != firstCell->width) || (encoder->height != firstCell->height))) {
+        avifDiagnosticsPrintf(&encoder->diag,
+                              "avifEncoder.width/height %ux%u must exactly match the coded size %ux%u of the last layer",
+                              encoder->width,
+                              encoder->height,
+                              firstCell->width,
+                              firstCell->height);
+        return AVIF_RESULT_INCOMPATIBLE_IMAGE;
+    }
+
+    if (firstCell->transformFlags & AVIF_TRANSFORM_CLAP) {
+        avifCropRect cropRect;
+        if (!avifCropRectFromCleanApertureBox(&cropRect, &firstCell->clap, encoder->width, encoder->height, &encoder->diag)) {
+            return AVIF_RESULT_INVALID_ARGUMENT;
+        }
+    }
+
+    if ((encoder->data->items.count > 0) && (encoder->extraLayerCount > 0) &&
+        !avifImageHasEquivalentTransformProperties(firstCell, encoder->data->imageMetadata)) {
+        avifDiagnosticsPrintf(&encoder->diag,
+                              "When avifEncoder.width/height is set, 'pasp', 'clap', 'irot' and 'imir' must match across layers");
+        return AVIF_RESULT_INCOMPATIBLE_IMAGE;
+    }
+
+    return AVIF_RESULT_OK;
+}
+
 // This function is called after every color frame is encoded. It returns AVIF_TRUE if a keyframe needs to be forced for the next
 // alpha frame to be encoded, AVIF_FALSE otherwise.
 static avifBool avifEncoderDataShouldForceKeyframeForAlpha(const avifEncoderData * data,
@@ -1769,6 +1936,8 @@
         return AVIF_RESULT_NO_CONTENT;
     }
 
+    AVIF_CHECKRES(avifEncoderValidateSize(encoder, gridCols, gridRows, firstCell));
+
     AVIF_CHECKRES(avifValidateGrid(gridCols, gridRows, cellImages, /*validateGainMap=*/AVIF_FALSE, &encoder->diag));
 
     const avifBool hasGainMap = (firstCell->gainMap && firstCell->gainMap->image != NULL);
@@ -1822,7 +1991,7 @@
         encoder->data->singleImage = AVIF_TRUE;
 
         if (encoder->extraLayerCount > 0) {
-            // AVIF_ADD_IMAGE_FLAG_SINGLE may not be set for layered image.
+            // AVIF_ADD_IMAGE_FLAG_SINGLE may not be set for a layered image.
             return AVIF_RESULT_INVALID_ARGUMENT;
         }
 
@@ -1899,6 +2068,10 @@
     if (encoder->data->items.count == 0) {
         // Make a copy of the first image's metadata (sans pixels) for future writing/validation
         AVIF_CHECKRES(avifImageCopy(encoder->data->imageMetadata, firstCell, 0));
+        if (avifEncoderSizeIsSet(encoder)) {
+            encoder->data->imageMetadata->width = encoder->width;
+            encoder->data->imageMetadata->height = encoder->height;
+        }
 
         const uint32_t gridWidth = avifGridWidth(gridCols, firstCell, bottomRightCell);
         const uint32_t gridHeight = avifGridHeight(gridRows, firstCell, bottomRightCell);
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 96ca5d7..8a3dd79 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -220,6 +220,7 @@
     add_avif_fuzztest(avif_fuzztest_dec_incr gtest/avifincrtest_helpers.cc)
     add_avif_fuzztest(avif_fuzztest_enc_dec)
     add_avif_fuzztest(avif_fuzztest_enc_dec_anim)
+    add_avif_fuzztest(avif_fuzztest_enc_dec_layered)
     add_avif_fuzztest(avif_fuzztest_enc_dec_incr gtest/avifincrtest_helpers.cc)
     add_avif_fuzztest(avif_fuzztest_properties)
     add_avif_fuzztest(avif_fuzztest_read_image)
diff --git a/tests/data/kodim03_yuv420_8bpc_384x256.y4m b/tests/data/kodim03_yuv420_8bpc_384x256.y4m
new file mode 100644
index 0000000..3301162
--- /dev/null
+++ b/tests/data/kodim03_yuv420_8bpc_384x256.y4m
Binary files differ
diff --git a/tests/gtest/avif_fuzztest_enc_dec_layered.cc b/tests/gtest/avif_fuzztest_enc_dec_layered.cc
new file mode 100644
index 0000000..ddc2eb0
--- /dev/null
+++ b/tests/gtest/avif_fuzztest_enc_dec_layered.cc
@@ -0,0 +1,217 @@
+// Copyright 2026 Google LLC
+// SPDX-License-Identifier: BSD-2-Clause
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <vector>
+
+#include "avif/avif.h"
+#include "avif_fuzztest_helpers.h"
+#include "aviftest_helpers.h"
+#include "fuzztest/fuzztest.h"
+#include "gtest/gtest.h"
+
+namespace avif {
+namespace testutil {
+namespace {
+
+constexpr size_t kLayerCount = kMaxNumLayers;
+
+constexpr avifFraction kNoScale = {1, 1};
+constexpr size_t kRandomScalingModeCount = kLayerCount - 1;
+static_assert(kRandomScalingModeCount == 3);
+constexpr std::array<avifFraction, 6> kSupportedScalingModes = {
+    avifFraction{1, 8}, avifFraction{1, 4}, avifFraction{1, 2},
+    avifFraction{3, 5}, avifFraction{3, 4}, avifFraction{4, 5}};
+constexpr avifScalingMode kNoScalingMode = {kNoScale, kNoScale};
+constexpr std::array<avifScalingMode, kLayerCount> kNoScalingModes = {
+    kNoScalingMode, kNoScalingMode, kNoScalingMode, kNoScalingMode};
+
+constexpr avifScalingMode MakeScalingMode(avifFraction horizontal,
+                                          avifFraction vertical) {
+  return {horizontal, vertical};
+}
+
+constexpr size_t BinomialCoefficient(size_t n, size_t k) {
+  if (k > n) {
+    return 0;
+  }
+  if (k > (n - k)) {
+    k = n - k;
+  }
+  size_t coefficient = 1;
+  for (size_t i = 1; i <= k; ++i) {
+    coefficient = (coefficient * (n - k + i)) / i;
+  }
+  return coefficient;
+}
+
+constexpr size_t CombinationCountWithRepetition(size_t value_count,
+                                                size_t pick_count) {
+  return BinomialCoefficient(value_count + pick_count - 1, pick_count);
+}
+
+constexpr size_t kScalingModeCombinationCount = CombinationCountWithRepetition(
+    kSupportedScalingModes.size(), kRandomScalingModeCount);
+
+std::array<avifFraction, kRandomScalingModeCount> GetScalingModeCombination(
+    size_t combination_index) {
+  std::array<avifFraction, kRandomScalingModeCount> combination = {};
+  size_t remaining_index = combination_index;
+  size_t next_candidate = 0;
+  for (size_t slot = 0; slot < combination.size(); ++slot) {
+    const size_t remaining_slots = combination.size() - slot - 1;
+    for (size_t candidate = next_candidate;
+         candidate < kSupportedScalingModes.size(); ++candidate) {
+      const size_t combinations_with_candidate = CombinationCountWithRepetition(
+          kSupportedScalingModes.size() - candidate, remaining_slots);
+      if (remaining_index < combinations_with_candidate) {
+        combination[slot] = kSupportedScalingModes[candidate];
+        next_candidate = candidate;
+        break;
+      }
+      remaining_index -= combinations_with_candidate;
+    }
+  }
+  return combination;
+}
+
+std::array<avifScalingMode, kLayerCount> MakeRandomScalingModes(
+    int horizontal_combination_index, int vertical_combination_index) {
+  const auto horizontal_combination =
+      GetScalingModeCombination(horizontal_combination_index);
+  const auto vertical_combination =
+      GetScalingModeCombination(vertical_combination_index);
+  return std::array<avifScalingMode, kLayerCount>{
+      MakeScalingMode(horizontal_combination[0], vertical_combination[0]),
+      MakeScalingMode(horizontal_combination[1], vertical_combination[1]),
+      MakeScalingMode(horizontal_combination[2], vertical_combination[2]),
+      kNoScalingMode};
+}
+
+inline auto ArbitraryScalingModes() {
+  constexpr int kMaxCombinationIndex =
+      static_cast<int>(kScalingModeCombinationCount - 1);
+  return fuzztest::Map(MakeRandomScalingModes,
+                       fuzztest::InRange<int>(0, kMaxCombinationIndex),
+                       fuzztest::InRange<int>(0, kMaxCombinationIndex));
+}
+
+void EncodeDecodeLayered(
+    std::vector<ImagePtr> layers,
+    const std::array<avifScalingMode, kLayerCount>& scaling_modes,
+    uint32_t expected_width, uint32_t expected_height, bool set_last_layer_size,
+    EncoderPtr encoder, DecoderPtr decoder) {
+  ASSERT_EQ(layers.size(), kLayerCount);
+  ASSERT_NE(encoder, nullptr);
+  ASSERT_NE(decoder, nullptr);
+
+  if (avifCodecName(AVIF_CODEC_CHOICE_AOM, AVIF_CODEC_FLAG_CAN_ENCODE) ==
+      nullptr) {
+    return;
+  }
+  if (avifLibYUVVersion() == 0) {
+    return;
+  }
+
+  const avifImage* const reference = layers.front().get();
+  ASSERT_NE(reference, nullptr);
+
+  encoder->codecChoice = AVIF_CODEC_CHOICE_AOM;
+  encoder->extraLayerCount = static_cast<uint32_t>(layers.size() - 1);
+  if (set_last_layer_size) {
+    encoder->width = expected_width;
+    encoder->height = expected_height;
+  }
+
+  for (size_t i = 0; i < layers.size(); ++i) {
+    ASSERT_NE(layers[i].get(), nullptr);
+    encoder->scalingMode = scaling_modes[i];
+    const avifResult result = avifEncoderAddImage(
+        encoder.get(), layers[i].get(),
+        /*durationInTimescales=*/1, AVIF_ADD_IMAGE_FLAG_NONE);
+    ASSERT_EQ(result, AVIF_RESULT_OK)
+        << " layer " << i << ": " << avifResultToString(result) << ": "
+        << encoder->diag.error;
+  }
+
+  AvifRwData encoded_data;
+  avifResult result = avifEncoderFinish(encoder.get(), &encoded_data);
+  ASSERT_EQ(result, AVIF_RESULT_OK)
+      << avifResultToString(result) << ": " << encoder->diag.error;
+
+  result = avifDecoderSetIOMemory(decoder.get(), encoded_data.data,
+                                  encoded_data.size);
+  ASSERT_EQ(result, AVIF_RESULT_OK)
+      << avifResultToString(result) << ": " << decoder->diag.error;
+
+  result = avifDecoderParse(decoder.get());
+  ASSERT_EQ(result, AVIF_RESULT_OK)
+      << avifResultToString(result) << ": " << decoder->diag.error;
+
+  const int num_decodes =
+      decoder->allowProgressive ? static_cast<int>(layers.size()) : 1;
+  if (decoder->allowProgressive) {
+    EXPECT_EQ(decoder->progressiveState, AVIF_PROGRESSIVE_STATE_ACTIVE);
+    ASSERT_EQ(decoder->imageCount, static_cast<int>(layers.size()));
+  } else {
+    EXPECT_EQ(decoder->progressiveState, AVIF_PROGRESSIVE_STATE_AVAILABLE);
+    ASSERT_EQ(decoder->imageCount, 1);
+  }
+
+  for (int i = 0; i < num_decodes; ++i) {
+    result = avifDecoderNextImage(decoder.get());
+    ASSERT_EQ(result, AVIF_RESULT_OK)
+        << " layer " << i << ": " << avifResultToString(result) << ": "
+        << decoder->diag.error;
+    EXPECT_EQ(decoder->image->width, expected_width);
+    EXPECT_EQ(decoder->image->height, expected_height);
+    EXPECT_EQ(decoder->image->depth, reference->depth);
+    EXPECT_EQ(decoder->image->yuvFormat, reference->yuvFormat);
+  }
+  result = avifDecoderNextImage(decoder.get());
+  ASSERT_EQ(result, AVIF_RESULT_NO_IMAGES_REMAINING)
+      << avifResultToString(result) << ": " << decoder->diag.error;
+}
+
+void EncodeDecodeDimensionChange(
+    std::vector<ImagePtr> layers,
+    const std::array<avifScalingMode, kLayerCount>& scaling_modes,
+    EncoderPtr encoder, DecoderPtr decoder) {
+  ASSERT_EQ(layers.size(), kLayerCount);
+  ASSERT_NE(layers.back().get(), nullptr);
+  const uint32_t expected_width = layers.back()->width;
+  const uint32_t expected_height = layers.back()->height;
+  EncodeDecodeLayered(std::move(layers), scaling_modes,
+                      /*expected_width=*/expected_width,
+                      /*expected_height=*/expected_height,
+                      /*set_last_layer_size=*/false, std::move(encoder),
+                      std::move(decoder));
+}
+
+void EncodeDecodeDimensionChangeExternal(std::vector<ImagePtr> layers,
+                                         EncoderPtr encoder,
+                                         DecoderPtr decoder) {
+  ASSERT_EQ(layers.size(), kLayerCount);
+  ASSERT_NE(layers.back().get(), nullptr);
+  const uint32_t expected_width = layers.back()->width;
+  const uint32_t expected_height = layers.back()->height;
+  EncodeDecodeLayered(std::move(layers), kNoScalingModes,
+                      /*expected_width=*/expected_width,
+                      /*expected_height=*/expected_height,
+                      /*set_last_layer_size=*/true, std::move(encoder),
+                      std::move(decoder));
+}
+
+FUZZ_TEST(LayeredEncodeDecodeAvifFuzzTest, EncodeDecodeDimensionChange)
+    .WithDomains(ArbitraryAvifLayered(), ArbitraryScalingModes(),
+                 ArbitraryAvifEncoder(), ArbitraryAvifDecoder());
+
+FUZZ_TEST(LayeredEncodeDecodeAvifFuzzTest, EncodeDecodeDimensionChangeExternal)
+    .WithDomains(ArbitraryAvifLayeredRandDim(), ArbitraryAvifEncoder(),
+                 ArbitraryAvifDecoder());
+
+}  // namespace
+}  // namespace testutil
+}  // namespace avif
diff --git a/tests/gtest/avif_fuzztest_helpers.cc b/tests/gtest/avif_fuzztest_helpers.cc
index 95d7693..e73ab73 100644
--- a/tests/gtest/avif_fuzztest_helpers.cc
+++ b/tests/gtest/avif_fuzztest_helpers.cc
@@ -4,6 +4,7 @@
 #include "avif_fuzztest_helpers.h"
 
 #include <algorithm>
+#include <array>
 #include <cassert>
 #include <cstdint>
 #include <cstdlib>
@@ -22,6 +23,27 @@
 
 //------------------------------------------------------------------------------
 
+constexpr size_t kNumLayeredRandDimSeeds = 2 * (kMaxNumLayers - 1);
+
+template <typename Sample>
+std::array<std::pair<size_t, size_t>, kMaxNumLayers> GetAvifLayeredRandDims(
+    size_t display_width, size_t display_height, const Sample* seeds) {
+  assert(display_width > 1);
+  assert(display_height > 1);
+  std::array<std::pair<size_t, size_t>, kMaxNumLayers> sizes = {};
+  for (size_t i = 0; i + 1 < kMaxNumLayers; ++i) {
+    const size_t width =
+        1 + (static_cast<size_t>(seeds[2 * i]) % (display_width - 1));
+    const size_t height =
+        1 + (static_cast<size_t>(seeds[2 * i + 1]) % (display_height - 1));
+    assert(width < display_width);
+    assert(height < display_height);
+    sizes[i] = {width, height};
+  }
+  sizes.back() = {display_width, display_height};
+  return sizes;
+}
+
 ImagePtr CreateAvifImage(size_t width, size_t height, int depth,
                          avifPixelFormat pixel_format, bool has_alpha,
                          const uint8_t* samples) {
@@ -104,6 +126,76 @@
   return frames;
 }
 
+std::vector<ImagePtr> CreateAvifLayered8b(size_t width, size_t height,
+                                          avifPixelFormat pixel_format,
+                                          bool has_alpha,
+                                          const std::vector<uint8_t>& samples) {
+  std::vector<ImagePtr> layers = CreateAvifAnim8b(
+      kMaxNumLayers, width, height, pixel_format, has_alpha, samples);
+  return layers;
+}
+
+std::vector<ImagePtr> CreateAvifLayered16b(
+    size_t width, size_t height, int depth, avifPixelFormat pixel_format,
+    bool has_alpha, const std::vector<uint16_t>& samples) {
+  std::vector<ImagePtr> layers = CreateAvifAnim16b(
+      kMaxNumLayers, width, height, depth, pixel_format, has_alpha, samples);
+  return layers;
+}
+
+size_t GetNumSamplesLayeredRandDim(size_t display_width, size_t display_height,
+                                   avifPixelFormat pixel_format,
+                                   bool has_alpha) {
+  return kNumLayeredRandDimSeeds + GetNumSamples(kMaxNumLayers, display_width,
+                                                 display_height, pixel_format,
+                                                 has_alpha);
+}
+
+std::vector<ImagePtr> CreateAvifLayeredRandDim8b(
+    size_t display_width, size_t display_height, avifPixelFormat pixel_format,
+    bool has_alpha, const std::vector<uint8_t>& samples) {
+  assert(samples.size() >= kNumLayeredRandDimSeeds);
+  const auto dims =
+      GetAvifLayeredRandDims(display_width, display_height, samples.data());
+  std::vector<ImagePtr> layers;
+  layers.reserve(kMaxNumLayers);
+
+  size_t offset = kNumLayeredRandDimSeeds;
+  for (const auto& [width, height] : dims) {
+    const size_t num_samples = GetNumSamples(
+        /*num_frames=*/1, width, height, pixel_format, has_alpha);
+    layers.push_back(CreateAvifImage8b(
+        width, height, pixel_format, has_alpha,
+        std::vector<uint8_t>(samples.begin() + offset,
+                             samples.begin() + offset + num_samples)));
+    offset += num_samples;
+  }
+  return layers;
+}
+
+std::vector<ImagePtr> CreateAvifLayeredRandDim16b(
+    size_t display_width, size_t display_height, int depth,
+    avifPixelFormat pixel_format, bool has_alpha,
+    const std::vector<uint16_t>& samples) {
+  assert(samples.size() >= kNumLayeredRandDimSeeds);
+  const auto dims =
+      GetAvifLayeredRandDims(display_width, display_height, samples.data());
+  std::vector<ImagePtr> layers;
+  layers.reserve(kMaxNumLayers);
+
+  size_t offset = kNumLayeredRandDimSeeds;
+  for (const auto& [width, height] : dims) {
+    const size_t num_samples = GetNumSamples(
+        /*num_frames=*/1, width, height, pixel_format, has_alpha);
+    layers.push_back(CreateAvifImage16b(
+        width, height, depth, pixel_format, has_alpha,
+        std::vector<uint16_t>(samples.begin() + offset,
+                              samples.begin() + offset + num_samples)));
+    offset += num_samples;
+  }
+  return layers;
+}
+
 EncoderPtr CreateAvifEncoder(avifCodecChoice codec_choice, int max_threads,
                              int quality, int quality_alpha, int tile_rows_log2,
                              int tile_cols_log2, int speed) {
diff --git a/tests/gtest/avif_fuzztest_helpers.h b/tests/gtest/avif_fuzztest_helpers.h
index 6a8fbf5..849f368 100644
--- a/tests/gtest/avif_fuzztest_helpers.h
+++ b/tests/gtest/avif_fuzztest_helpers.h
@@ -58,6 +58,20 @@
                                         avifPixelFormat pixel_format,
                                         bool has_alpha,
                                         const std::vector<uint16_t>& samples);
+std::vector<ImagePtr> CreateAvifLayered8b(size_t width, size_t height,
+                                          avifPixelFormat pixel_format,
+                                          bool has_alpha,
+                                          const std::vector<uint8_t>& samples);
+std::vector<ImagePtr> CreateAvifLayered16b(
+    size_t width, size_t height, int depth, avifPixelFormat pixel_format,
+    bool has_alpha, const std::vector<uint16_t>& samples);
+std::vector<ImagePtr> CreateAvifLayeredRandDim8b(
+    size_t display_width, size_t display_height, avifPixelFormat pixel_format,
+    bool has_alpha, const std::vector<uint8_t>& samples);
+std::vector<ImagePtr> CreateAvifLayeredRandDim16b(
+    size_t display_width, size_t display_height, int depth,
+    avifPixelFormat pixel_format, bool has_alpha,
+    const std::vector<uint16_t>& samples);
 EncoderPtr CreateAvifEncoder(avifCodecChoice codec_choice, int max_threads,
                              int quality, int quality_alpha, int tile_rows_log2,
                              int tile_cols_log2, int speed);
@@ -84,9 +98,14 @@
 // Do not generate animations with more than this number of frames.
 inline constexpr size_t kMaxNumFrames =
     kMaxNumFramesSquareRoot * kMaxNumFramesSquareRoot;
+// Do not generate layered images with more than this number of layers.
+inline constexpr size_t kMaxNumLayers = AVIF_MAX_AV1_LAYER_COUNT;
 
 size_t GetNumSamples(size_t num_frames, size_t width, size_t height,
                      avifPixelFormat pixel_format, bool has_alpha);
+size_t GetNumSamplesLayeredRandDim(size_t display_width, size_t display_height,
+                                   avifPixelFormat pixel_format,
+                                   bool has_alpha);
 
 // To avoid using fuzztest::internal, the return type of the functions below is
 // auto.
@@ -186,6 +205,107 @@
   return fuzztest::OneOf(ArbitraryAvifAnim8b(), ArbitraryAvifAnim16b());
 }
 
+// Layered avifImage generator type: fixed number of layers, same width/height,
+// pixel format and 8-bit samples.
+inline auto ArbitraryAvifLayered8b() {
+  constexpr uint16_t kMinLayerDimension = 8;
+  constexpr uint16_t kMaxLayerDimension =
+      kMaxDimension / kMaxNumFramesSquareRoot;
+  return fuzztest::FlatMap(
+      [](size_t width, size_t height, avifPixelFormat pixel_format,
+         bool has_alpha) {
+        return fuzztest::Map(
+            CreateAvifLayered8b, fuzztest::Just(width), fuzztest::Just(height),
+            fuzztest::Just(pixel_format), fuzztest::Just(has_alpha),
+            fuzztest::Arbitrary<std::vector<uint8_t>>().WithSize(GetNumSamples(
+                kMaxNumLayers, width, height, pixel_format, has_alpha)));
+      },
+      fuzztest::InRange<uint16_t>(kMinLayerDimension, kMaxLayerDimension),
+      fuzztest::InRange<uint16_t>(kMinLayerDimension, kMaxLayerDimension),
+      ArbitraryPixelFormat(), fuzztest::Arbitrary<bool>());
+}
+
+// Layered avifImage generator type: fixed number of layers, same width/height,
+// depth, pixel format and 16-bit samples.
+inline auto ArbitraryAvifLayered16b() {
+  constexpr uint16_t kMinLayerDimension = 8;
+  constexpr uint16_t kMaxLayerDimension =
+      kMaxDimension / kMaxNumFramesSquareRoot;
+  return fuzztest::FlatMap(
+      [](size_t width, size_t height, int depth, avifPixelFormat pixel_format,
+         bool has_alpha) {
+        return fuzztest::Map(
+            CreateAvifLayered16b, fuzztest::Just(width), fuzztest::Just(height),
+            fuzztest::Just(depth), fuzztest::Just(pixel_format),
+            fuzztest::Just(has_alpha),
+            fuzztest::ContainerOf<std::vector<uint16_t>>(
+                fuzztest::InRange<uint16_t>(0, (1 << depth) - 1))
+                .WithSize(GetNumSamples(kMaxNumLayers, width, height,
+                                        pixel_format, has_alpha)));
+      },
+      fuzztest::InRange<uint16_t>(kMinLayerDimension, kMaxLayerDimension),
+      fuzztest::InRange<uint16_t>(kMinLayerDimension, kMaxLayerDimension),
+      fuzztest::ElementOf({10, 12}), ArbitraryPixelFormat(),
+      fuzztest::Arbitrary<bool>());
+}
+
+// Generator for an arbitrary layered still image with same-sized layers.
+inline auto ArbitraryAvifLayered() {
+  return fuzztest::OneOf(ArbitraryAvifLayered8b(), ArbitraryAvifLayered16b());
+}
+
+// Layered avifImage generator type: fixed number of layers, display size,
+// pixel format and 8-bit samples.
+inline auto ArbitraryAvifLayeredRandDim8b() {
+  constexpr uint16_t kMinLayerDimension = 8;
+  constexpr uint16_t kMaxLayerDimension =
+      kMaxDimension / kMaxNumFramesSquareRoot;
+  return fuzztest::FlatMap(
+      [](size_t display_width, size_t display_height,
+         avifPixelFormat pixel_format, bool has_alpha) {
+        return fuzztest::Map(
+            CreateAvifLayeredRandDim8b, fuzztest::Just(display_width),
+            fuzztest::Just(display_height), fuzztest::Just(pixel_format),
+            fuzztest::Just(has_alpha),
+            fuzztest::Arbitrary<std::vector<uint8_t>>().WithSize(
+                GetNumSamplesLayeredRandDim(display_width, display_height,
+                                            pixel_format, has_alpha)));
+      },
+      fuzztest::InRange<uint16_t>(kMinLayerDimension, kMaxLayerDimension),
+      fuzztest::InRange<uint16_t>(kMinLayerDimension, kMaxLayerDimension),
+      ArbitraryPixelFormat(), fuzztest::Arbitrary<bool>());
+}
+
+// Layered avifImage generator type: fixed number of layers, display size,
+// depth, pixel format and 16-bit samples.
+inline auto ArbitraryAvifLayeredRandDim16b() {
+  constexpr uint16_t kMinLayerDimension = 8;
+  constexpr uint16_t kMaxLayerDimension =
+      kMaxDimension / kMaxNumFramesSquareRoot;
+  return fuzztest::FlatMap(
+      [](size_t display_width, size_t display_height, int depth,
+         avifPixelFormat pixel_format, bool has_alpha) {
+        return fuzztest::Map(
+            CreateAvifLayeredRandDim16b, fuzztest::Just(display_width),
+            fuzztest::Just(display_height), fuzztest::Just(depth),
+            fuzztest::Just(pixel_format), fuzztest::Just(has_alpha),
+            fuzztest::ContainerOf<std::vector<uint16_t>>(
+                fuzztest::InRange<uint16_t>(0, (1 << depth) - 1))
+                .WithSize(GetNumSamplesLayeredRandDim(
+                    display_width, display_height, pixel_format, has_alpha)));
+      },
+      fuzztest::InRange<uint16_t>(kMinLayerDimension, kMaxLayerDimension),
+      fuzztest::InRange<uint16_t>(kMinLayerDimension, kMaxLayerDimension),
+      fuzztest::ElementOf({10, 12}), ArbitraryPixelFormat(),
+      fuzztest::Arbitrary<bool>());
+}
+
+// Generator for an arbitrary layered still image with display size override.
+inline auto ArbitraryAvifLayeredRandDim() {
+  return fuzztest::OneOf(ArbitraryAvifLayeredRandDim8b(),
+                         ArbitraryAvifLayeredRandDim16b());
+}
+
 // Generates two signed fractions where the first one is smaller than or equal
 // to the second one.
 inline auto ArbitraryMinMaxSignedFraction() {
diff --git a/tests/gtest/avifchangesettingtest.cc b/tests/gtest/avifchangesettingtest.cc
index 485a87b..de4ffe4 100644
--- a/tests/gtest/avifchangesettingtest.cc
+++ b/tests/gtest/avifchangesettingtest.cc
@@ -241,6 +241,42 @@
             AVIF_RESULT_INCOMPATIBLE_IMAGE);
 }
 
+TEST(ChangeSettingTest, UnchangeableEncoderSize) {
+  if (avifCodecName(AVIF_CODEC_CHOICE_AOM, AVIF_CODEC_FLAG_CAN_ENCODE) ==
+      nullptr) {
+    GTEST_SKIP() << "Codec unavailable, skip test.";
+  }
+
+  ImagePtr image = testutil::CreateImage(/*width=*/64, /*height=*/64,
+                                         /*depth=*/8, AVIF_PIXEL_FORMAT_YUV444,
+                                         AVIF_PLANES_YUV, AVIF_RANGE_FULL);
+  ASSERT_NE(image, nullptr);
+  testutil::FillImageGradient(image.get());
+
+  EncoderPtr encoder(avifEncoderCreate());
+  ASSERT_NE(encoder, nullptr);
+  encoder->codecChoice = AVIF_CODEC_CHOICE_AOM;
+  encoder->speed = AVIF_SPEED_FASTEST;
+  encoder->extraLayerCount = 1;
+  encoder->width = 128;
+  encoder->height = 128;
+
+  ASSERT_EQ(avifEncoderAddImage(encoder.get(), image.get(), 1,
+                                AVIF_ADD_IMAGE_FLAG_NONE),
+            AVIF_RESULT_OK);
+
+  ImagePtr image2 = testutil::CreateImage(/*width=*/256, /*height=*/128,
+                                          /*depth=*/8, AVIF_PIXEL_FORMAT_YUV444,
+                                          AVIF_PLANES_YUV, AVIF_RANGE_FULL);
+  ASSERT_NE(image2, nullptr);
+  testutil::FillImageGradient(image2.get());
+
+  encoder->width = 256;
+  ASSERT_EQ(avifEncoderAddImage(encoder.get(), image2.get(), 1,
+                                AVIF_ADD_IMAGE_FLAG_NONE),
+            AVIF_RESULT_CANNOT_CHANGE_SETTING);
+}
+
 void EncodeAnimation(const char* key, const char* value_before_first_frame,
                      const char* value_after_first_frame,
                      const char* value_before_second_frame,
diff --git a/tests/gtest/avifprogressivetest.cc b/tests/gtest/avifprogressivetest.cc
index 3071b56..641a9d9 100644
--- a/tests/gtest/avifprogressivetest.cc
+++ b/tests/gtest/avifprogressivetest.cc
@@ -129,7 +129,7 @@
 //   `ms_params->ms_buffers.ref->stride == ms_params->search_sites->stride'
 //   failed.
 // See https://aomedia.googlesource.com/aom/+/945edd671.
-TEST_F(ProgressiveTest, DimensionChange) {
+TEST_F(ProgressiveTest, ResolutionChange) {
   if (avifLibYUVVersion() == 0) {
     GTEST_SKIP() << "libyuv not available, skip test.";
   }
@@ -152,7 +152,7 @@
   TestDecode(kImageSize, kImageSize);
 }
 
-TEST_F(ProgressiveTest, DimensionChangeWithAlpha) {
+TEST_F(ProgressiveTest, ResolutionChangeWithAlpha) {
   if (avifLibYUVVersion() == 0) {
     GTEST_SKIP() << "libyuv not available, skip test.";
   }
@@ -187,6 +187,112 @@
   TestDecode(kImageSize, kImageSize, /*expect_alpha=*/true);
 }
 
+TEST_F(ProgressiveTest, ResolutionChangeExternal) {
+  encoder_->extraLayerCount = 1;
+  encoder_->width = kImageSize;
+  encoder_->height = kImageSize;
+
+  const auto smallImage = testutil::CreateImage(
+      kImageSize / 2, kImageSize / 2, 8, AVIF_PIXEL_FORMAT_YUV444,
+      AVIF_PLANES_YUV, AVIF_RANGE_FULL);
+  ASSERT_NE(smallImage, nullptr);
+  testutil::FillImageGradient(smallImage.get(), /*offset=*/17);
+
+  ASSERT_EQ(avifEncoderAddImage(encoder_.get(), smallImage.get(), 1,
+                                AVIF_ADD_IMAGE_FLAG_NONE),
+            AVIF_RESULT_OK);
+
+  ASSERT_EQ(avifEncoderAddImage(encoder_.get(), image_.get(), 1,
+                                AVIF_ADD_IMAGE_FLAG_NONE),
+            AVIF_RESULT_OK);
+
+  ASSERT_EQ(avifEncoderFinish(encoder_.get(), &encoded_avif_), AVIF_RESULT_OK);
+
+  TestDecode(256, 256);
+}
+
+TEST_F(ProgressiveTest, ResolutionChangeExternalWithAlpha) {
+  const auto image =
+      testutil::CreateImage(kImageSize, kImageSize, 8, AVIF_PIXEL_FORMAT_YUV444,
+                            AVIF_PLANES_ALL, AVIF_RANGE_FULL);
+  ASSERT_NE(image, nullptr);
+  testutil::FillImageGradient(image.get());
+
+  encoder_->extraLayerCount = 2;
+  encoder_->width = kImageSize;
+  encoder_->height = kImageSize;
+
+  auto smallImage = testutil::CreateImage(kImageSize / 2, kImageSize / 2, 8,
+                                          AVIF_PIXEL_FORMAT_YUV444,
+                                          AVIF_PLANES_ALL, AVIF_RANGE_FULL);
+  ASSERT_NE(smallImage, nullptr);
+  testutil::FillImageGradient(smallImage.get(), /*offset=*/17);
+
+  ASSERT_EQ(avifEncoderAddImage(encoder_.get(), smallImage.get(), 1,
+                                AVIF_ADD_IMAGE_FLAG_NONE),
+            AVIF_RESULT_OK);
+
+  ASSERT_EQ(avifEncoderAddImage(encoder_.get(), smallImage.get(), 1,
+                                AVIF_ADD_IMAGE_FLAG_NONE),
+            AVIF_RESULT_OK);
+
+  ASSERT_EQ(avifEncoderAddImage(encoder_.get(), image.get(), 1,
+                                AVIF_ADD_IMAGE_FLAG_NONE),
+            AVIF_RESULT_OK);
+
+  ASSERT_EQ(avifEncoderFinish(encoder_.get(), &encoded_avif_), AVIF_RESULT_OK);
+
+  TestDecode(kImageSize, kImageSize, /*expect_alpha=*/true);
+}
+
+TEST_F(ProgressiveTest, ResolutionChangeExternalBadSize) {
+  encoder_->extraLayerCount = 1;
+  encoder_->width = kImageSize * 2;
+  encoder_->height = kImageSize * 2;
+
+  ASSERT_EQ(avifEncoderAddImage(encoder_.get(), image_.get(), 1,
+                                AVIF_ADD_IMAGE_FLAG_NONE),
+            AVIF_RESULT_OK);
+
+  // Last layer does not match the pre-declared size
+  ASSERT_EQ(avifEncoderAddImage(encoder_.get(), image_.get(), 1,
+                                AVIF_ADD_IMAGE_FLAG_NONE),
+            AVIF_RESULT_INCOMPATIBLE_IMAGE);
+}
+
+// Reject mixed usage of internal and external resolution change.
+TEST_F(ProgressiveTest, ResolutionChangeMixedRejected) {
+  encoder_->extraLayerCount = 1;
+  encoder_->width = kImageSize;
+  encoder_->height = kImageSize;
+  encoder_->scalingMode = {{1, 2}, {1, 2}};
+
+  ASSERT_EQ(avifEncoderAddImage(encoder_.get(), image_.get(), 1,
+                                AVIF_ADD_IMAGE_FLAG_NONE),
+            AVIF_RESULT_INVALID_ARGUMENT);
+}
+
+TEST_F(ProgressiveTest, ResolutionChangeMixedDuringEncodingRejected) {
+  encoder_->extraLayerCount = 2;
+  encoder_->width = kImageSize;
+  encoder_->height = kImageSize;
+
+  const auto smallImage = testutil::CreateImage(
+      kImageSize / 2, kImageSize / 2, 8, AVIF_PIXEL_FORMAT_YUV444,
+      AVIF_PLANES_YUV, AVIF_RANGE_FULL);
+  ASSERT_NE(smallImage, nullptr);
+  testutil::FillImageGradient(smallImage.get(), /*offset=*/17);
+
+  ASSERT_EQ(avifEncoderAddImage(encoder_.get(), smallImage.get(), 1,
+                                AVIF_ADD_IMAGE_FLAG_NONE),
+            AVIF_RESULT_OK);
+
+  encoder_->scalingMode = {{1, 2}, {1, 2}};
+  ASSERT_EQ(avifEncoderAddImage(encoder_.get(), image_.get(), 1,
+                                AVIF_ADD_IMAGE_FLAG_NONE),
+            AVIF_RESULT_INVALID_ARGUMENT);
+}
+
 TEST_F(ProgressiveTest, LayeredGrid) {
   encoder_->extraLayerCount = 1;
   encoder_->quality = 21;
diff --git a/tests/test_cmd_progressive.sh b/tests/test_cmd_progressive.sh
index 6492ee0..c2e1583 100755
--- a/tests/test_cmd_progressive.sh
+++ b/tests/test_cmd_progressive.sh
@@ -22,14 +22,16 @@
 
 # Input file paths.
 INPUT_Y4M="${TESTDATA_DIR}/kodim03_yuv420_8bpc.y4m"
+INPUT_SMALL_Y4M="${TESTDATA_DIR}/kodim03_yuv420_8bpc_384x256.y4m"
 # Output file names.
 ENCODED_FILE="avif_test_cmd_encoded.avif"
 DECODED_FILE="avif_test_cmd_decoded.png"
+INFO_FILE="avif_test_cmd_info.txt"
 
 # Cleanup
 cleanup() {
   pushd ${TMP_DIR}
-    rm -- "${ENCODED_FILE}" "${DECODED_FILE}"
+    rm -- "${ENCODED_FILE}" "${DECODED_FILE}" "${INFO_FILE}"
   popd
 }
 trap cleanup EXIT
@@ -55,6 +57,22 @@
     done
   fi
 
+  echo "Testing manual layered encoding with pre-scaled input"
+  "${AVIFENC}" -s 8 --layered \
+    "${INPUT_SMALL_Y4M}" "${INPUT_Y4M}" -o "${ENCODED_FILE}"
+  "${AVIFDEC}" --info "${ENCODED_FILE}" > "${INFO_FILE}"
+  grep -F "[768x512]" "${INFO_FILE}"
+  "${AVIFDEC}" "${ENCODED_FILE}" "${DECODED_FILE}"
+  "${AVIFDEC}" --progressive "${ENCODED_FILE}" "${DECODED_FILE}"
+
+  echo "Testing an earlier layer larger than the last layer"
+  "${AVIFENC}" -s 8 --layered \
+    "${INPUT_Y4M}" "${INPUT_SMALL_Y4M}" -o "${ENCODED_FILE}" && exit 1
+
+  echo "Testing layered encoding with pre-scaled input and frame scaling (rejected)"
+  "${AVIFENC}" -s 8 --layered --scaling-mode:u 1/2 \
+    "${INPUT_SMALL_Y4M}" "${INPUT_Y4M}" -o "${ENCODED_FILE}" && exit 1
+
   echo "Testing too few layers"
   "${AVIFENC}" -s 8 --layered -q:u 60 "${INPUT_Y4M}" -o "${ENCODED_FILE}" && exit 1