Expose avifDecoderItemExtent as avifExtent, and add avifDecoderNthImageMaxExtent() streaming helper function
diff --git a/include/avif/avif.h b/include/avif/avif.h
index 930a34a..806fd29 100644
--- a/include/avif/avif.h
+++ b/include/avif/avif.h
@@ -747,6 +747,34 @@
 avifResult avifDecoderNthImageTiming(const avifDecoder * decoder, uint32_t frameIndex, avifImageTiming * outTiming);
 
 // ---------------------------------------------------------------------------
+// avifExtent
+
+typedef struct avifExtent
+{
+    uint64_t offset;
+    size_t size;
+} avifExtent;
+
+// Streaming data helper - Use this to calculate the maximal AVIF data extent encompassing all AV1
+// sample data needed to decode the Nth image. The offset will be the earliest offset of all required
+// AV1 extents for this frame, and the size will create a range including the last byte of the last AV1
+// sample needed. Note that this extent may include non-sample data, as a frame's sample data
+// may be broken into multiple extents and interleaved with other data, or in non-sequential order.
+//
+// If includeDependentFrameExtents is true (recommended), this extent will also encompass all AV1
+// samples that this frame's sample depends on to decode, from the nearest keyframe up to this Nth
+// frame.
+//
+// If avifDecoderNthImageMaxExtent() returns AVIF_RESULT_OK and the extent's size is 0 bytes, the
+// data for this frame was read as a part of avifDecoderParse() (typically in an idat box inside of
+// a meta box) and no additional data will need to be read to decode this frame, assuming
+// includeDependentFrameExtents is true or this frameIndex is a keyframe.
+avifResult avifDecoderNthImageMaxExtent(const avifDecoder * decoder,
+                                        uint32_t frameIndex,
+                                        avifBool includeDependentFrameExtents,
+                                        avifExtent * outExtent);
+
+// ---------------------------------------------------------------------------
 // avifEncoder
 
 struct avifEncoderData;
diff --git a/include/avif/internal.h b/include/avif/internal.h
index 3aae746..3d0cab5 100644
--- a/include/avif/internal.h
+++ b/include/avif/internal.h
@@ -13,6 +13,7 @@
 // Yes, clamp macros are nasty. Do not use them.
 #define AVIF_CLAMP(x, low, high) (((x) < (low))) ? (low) : (((high) < (x)) ? (high) : (x))
 #define AVIF_MIN(a, b) (((a) < (b)) ? (a) : (b))
+#define AVIF_MAX(a, b) (((a) > (b)) ? (a) : (b))
 
 // Used by stream related things.
 #define CHECK(A)               \
diff --git a/src/read.c b/src/read.c
index 239f6cb..f510429 100644
--- a/src/read.c
+++ b/src/read.c
@@ -120,12 +120,7 @@
     return NULL;
 }
 
-typedef struct avifDecoderItemExtent
-{
-    uint64_t offset;
-    size_t size;
-} avifDecoderItemExtent;
-AVIF_ARRAY_DECLARE(avifDecoderItemExtentArray, avifDecoderItemExtent, extent);
+AVIF_ARRAY_DECLARE(avifExtentArray, avifExtent, extent);
 
 // one "item" worth for decoding (all iref, iloc, iprp, etc refer to one of these)
 typedef struct avifDecoderItem
@@ -137,7 +132,7 @@
     uint32_t idatID; // If non-zero, offset is relative to this idat box (iloc construction_method==1)
     avifContentType contentType;
     avifPropertyArray properties;
-    avifDecoderItemExtentArray extents; // All extent offsets/sizes
+    avifExtentArray extents;       // All extent offsets/sizes
     avifRWData mergedExtents;      // if set, is a single contiguous block of this item's extents (unused when extents.count == 1)
     avifBool ownsMergedExtents;    // if true, mergedExtents must be freed when this item is destroyed
     avifBool partialMergedExtents; // If true, mergedExtents doesn't have all of the item data yet
@@ -521,7 +516,7 @@
 
     avifDecoderItem * item = (avifDecoderItem *)avifArrayPushPtr(&meta->items);
     avifArrayCreate(&item->properties, sizeof(avifProperty), 16);
-    avifArrayCreate(&item->extents, sizeof(avifDecoderItemExtent), 1);
+    avifArrayCreate(&item->extents, sizeof(avifExtent), 1);
     item->id = itemID;
     item->meta = meta;
     return item;
@@ -625,6 +620,63 @@
     avifFree(data);
 }
 
+// This returns the max extent that has to be read in order to decode this item. If
+// the item is stored in an idat, the data has already been read during Parse() and
+// this function will return AVIF_RESULT_OK with a 0-byte extent.
+static avifResult avifDecoderItemMaxExtent(const avifDecoderItem * item, avifExtent * outExtent)
+{
+    if (item->extents.count == 0) {
+        return AVIF_RESULT_TRUNCATED_DATA;
+    }
+
+    if (item->idatID != 0) {
+        // construction_method: idat(1)
+
+        // Find associated idat block
+        for (uint32_t i = 0; i < item->meta->idats.count; ++i) {
+            if (item->meta->idats.idat[i].id == item->idatID) {
+                // Already read from a meta box during Parse()
+                memset(outExtent, 0, sizeof(avifExtent));
+                return AVIF_RESULT_OK;
+            }
+        }
+
+        // no idat box was found in this meta box, bail out
+        return AVIF_RESULT_NO_CONTENT;
+    }
+
+    // construction_method: file(0)
+
+    avifBool firstExtent = AVIF_TRUE;
+    uint64_t minOffset = 0;
+    uint64_t maxOffset = 0;
+    for (uint32_t extentIter = 0; extentIter < item->extents.count; ++extentIter) {
+        avifExtent * extent = &item->extents.extent[extentIter];
+
+        if (extent->size > UINT64_MAX - extent->offset) {
+            return AVIF_RESULT_BMFF_PARSE_FAILED;
+        }
+        const uint64_t endOffset = extent->offset + extent->size;
+
+        if (firstExtent) {
+            firstExtent = AVIF_FALSE;
+            minOffset = extent->offset;
+            maxOffset = endOffset;
+        } else {
+            if (minOffset > extent->offset) {
+                minOffset = extent->offset;
+            }
+            if (maxOffset < endOffset) {
+                maxOffset = endOffset;
+            }
+        }
+    }
+
+    outExtent->offset = minOffset;
+    outExtent->size = maxOffset - minOffset;
+    return AVIF_RESULT_OK;
+}
+
 static avifResult avifDecoderItemRead(avifDecoderItem * item, avifIO * io, avifROData * outData, size_t partialByteCount)
 {
     if (item->mergedExtents.data && !item->partialMergedExtents) {
@@ -684,7 +736,7 @@
     uint8_t * front = item->mergedExtents.data;
     size_t remainingBytes = totalBytesToRead;
     for (uint32_t extentIter = 0; extentIter < item->extents.count; ++extentIter) {
-        avifDecoderItemExtent * extent = &item->extents.extent[extentIter];
+        avifExtent * extent = &item->extents.extent[extentIter];
 
         size_t bytesToRead = extent->size;
         if (bytesToRead > remainingBytes) {
@@ -1110,7 +1162,7 @@
             uint64_t extentLength; // unsigned int(offset_size*8) extent_length;
             CHECK(avifROStreamReadUX8(&s, &extentLength, lengthSize));
 
-            avifDecoderItemExtent * extent = (avifDecoderItemExtent *)avifArrayPushPtr(&item->extents);
+            avifExtent * extent = (avifExtent *)avifArrayPushPtr(&item->extents);
             if (extentOffset > UINT64_MAX - baseOffset) {
                 return AVIF_FALSE;
             }
@@ -2234,6 +2286,72 @@
     return AVIF_RESULT_OK;
 }
 
+// 0-byte extents are ignored/overwritten during the merge, as they are the signal from helper
+// functions that no extent was necessary for this given sample. If both provided extents are
+// >0 bytes, this will set dst to be an extent that bounds both supplied extents.
+static void avifExtentMerge(avifExtent * dst, const avifExtent * src)
+{
+    if (!dst->size) {
+        memcpy(dst, src, sizeof(avifExtent));
+        return;
+    }
+    if (!src->size) {
+        return;
+    }
+
+    const uint64_t minExtent1 = dst->offset;
+    const uint64_t maxExtent1 = dst->offset + dst->size;
+    const uint64_t minExtent2 = src->offset;
+    const uint64_t maxExtent2 = src->offset + src->size;
+    dst->offset = AVIF_MIN(minExtent1, minExtent2);
+    dst->size = AVIF_MAX(maxExtent1, maxExtent2);
+}
+
+avifResult avifDecoderNthImageMaxExtent(const avifDecoder * decoder, uint32_t frameIndex, avifBool includeDependentFrameExtents, avifExtent * outExtent)
+{
+    if (!decoder->data) {
+        // Nothing has been parsed yet
+        return AVIF_RESULT_NO_CONTENT;
+    }
+
+    memset(outExtent, 0, sizeof(avifExtent));
+
+    uint32_t startFrameIndex = includeDependentFrameExtents ? avifDecoderNearestKeyframe(decoder, frameIndex) : frameIndex;
+    uint32_t endFrameIndex = frameIndex;
+    for (uint32_t currentFrameIndex = startFrameIndex; currentFrameIndex <= endFrameIndex; ++currentFrameIndex) {
+        for (unsigned int tileIndex = 0; tileIndex < decoder->data->tiles.count; ++tileIndex) {
+            avifTile * tile = &decoder->data->tiles.tile[tileIndex];
+            if (currentFrameIndex >= tile->input->samples.count) {
+                return AVIF_RESULT_NO_IMAGES_REMAINING;
+            }
+
+            avifDecodeSample * sample = &tile->input->samples.sample[currentFrameIndex];
+            avifExtent sampleExtent;
+            if (sample->itemID) {
+                // The data comes from an item. Let avifDecoderItemMaxExtent() do the heavy lifting.
+
+                avifDecoderItem * item = avifMetaFindItem(decoder->data->meta, sample->itemID);
+                avifResult maxExtentResult = avifDecoderItemMaxExtent(item, &sampleExtent);
+                if (maxExtentResult != AVIF_RESULT_OK) {
+                    return maxExtentResult;
+                }
+            } else {
+                // The data likely comes from a sample table. Use the sample position directly.
+
+                sampleExtent.offset = sample->offset;
+                sampleExtent.size = sample->size;
+            }
+
+            if (sampleExtent.size > UINT64_MAX - sampleExtent.offset) {
+                return AVIF_RESULT_BMFF_PARSE_FAILED;
+            }
+
+            avifExtentMerge(outExtent, &sampleExtent);
+        }
+    }
+    return AVIF_RESULT_OK;
+}
+
 static avifResult avifDecoderPrepareSample(avifDecoder * decoder, avifDecodeSample * sample, size_t partialByteCount)
 {
     if (!sample->data.size || sample->partialData) {
diff --git a/tests/aviftest.c b/tests/aviftest.c
index 770dd6a..c560e38 100644
--- a/tests/aviftest.c
+++ b/tests/aviftest.c
@@ -400,21 +400,37 @@
 
             if (parseResult == AVIF_RESULT_OK) {
                 for (; io->availableBytes <= io->io.sizeHint; ++io->availableBytes) {
-                    avifResult nextImageResult = avifDecoderNextImage(decoder);
-                    if (nextImageResult == AVIF_RESULT_WAITING_ON_IO) {
-                        continue;
-                    }
-                    if (nextImageResult != AVIF_RESULT_OK) {
+                    avifExtent extent;
+                    avifResult extentResult = avifDecoderNthImageMaxExtent(decoder, 0, AVIF_TRUE, &extent);
+                    if (extentResult != AVIF_RESULT_OK) {
                         retCode = 1;
-                    }
 
-                    printf("File: [%s @ %zu / %" PRIu64 " bytes, %s, %s] nextImage returned: %s\n",
-                           filename,
-                           io->availableBytes,
-                           io->io.sizeHint,
-                           io->io.persistent ? "Persistent" : "NonPersistent",
-                           decoder->ignoreExif ? "IgnoreMetadata" : "Metadata",
-                           avifResultToString(nextImageResult));
+                        printf("File: [%s @ %zu / %" PRIu64 " bytes, %s, %s] extentResult returned: %s\n",
+                               filename,
+                               io->availableBytes,
+                               io->io.sizeHint,
+                               io->io.persistent ? "Persistent" : "NonPersistent",
+                               decoder->ignoreExif ? "IgnoreMetadata" : "Metadata",
+                               avifResultToString(extentResult));
+                    } else {
+                        avifResult nextImageResult = avifDecoderNextImage(decoder);
+                        if (nextImageResult == AVIF_RESULT_WAITING_ON_IO) {
+                            continue;
+                        }
+                        if (nextImageResult != AVIF_RESULT_OK) {
+                            retCode = 1;
+                        }
+
+                        printf("File: [%s @ %zu / %" PRIu64 " bytes, %s, %s] nextImage [MaxExtent off %" PRIu64 ", size %zu] returned: %s\n",
+                               filename,
+                               io->availableBytes,
+                               io->io.sizeHint,
+                               io->io.persistent ? "Persistent" : "NonPersistent",
+                               decoder->ignoreExif ? "IgnoreMetadata" : "Metadata",
+                               extent.offset,
+                               extent.size,
+                               avifResultToString(nextImageResult));
+                    }
                     break;
                 }
             }