SDL_image: Added support for saving ANI animated cursors

From df4d9edaf8e4ab1a8097a84869f47f394e55bc20 Mon Sep 17 00:00:00 2001
From: Sam Lantinga <[EMAIL REDACTED]>
Date: Tue, 21 Oct 2025 12:25:00 -0700
Subject: [PATCH] Added support for saving ANI animated cursors

---
 CMakeLists.txt                 |   2 +
 include/SDL3_image/SDL_image.h |  48 +++++++
 src/IMG_ani.c                  | 221 +++++++++++++++++++++++++++++++++
 src/IMG_ani.h                  |   1 +
 src/IMG_anim_decoder.c         |  22 ++--
 src/IMG_anim_encoder.c         |  20 +--
 test/CMakeLists.txt            |   6 +-
 test/rgbrgb.ani                | Bin 0 -> 5404 bytes
 test/testanimation.c           |  29 +++--
 9 files changed, 319 insertions(+), 30 deletions(-)
 create mode 100755 test/rgbrgb.ani

diff --git a/CMakeLists.txt b/CMakeLists.txt
index ccf05a97..8180300f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -129,6 +129,7 @@ option(SDLIMAGE_XCF "Support loading XCF images" ON)
 option(SDLIMAGE_XPM "Support loading XPM images" ON)
 option(SDLIMAGE_XV "Support loading XV images" ON)
 
+cmake_dependent_option(SDLIMAGE_ANI_SAVE "Add ANI save support" ON SDLIMAGE_ANI OFF)
 cmake_dependent_option(SDLIMAGE_AVIF_SAVE "Add AVIF save support" ON SDLIMAGE_AVIF OFF)
 cmake_dependent_option(SDLIMAGE_BMP_SAVE "Add BMP save support" ON SDLIMAGE_BMP OFF)
 cmake_dependent_option(SDLIMAGE_GIF_SAVE "Add GIF save support" ON SDLIMAGE_GIF OFF)
@@ -603,6 +604,7 @@ if(SDLIMAGE_ANI)
     if(SDLIMAGE_ANI_ENABLED)
         target_compile_definitions(${sdl3_image_target_name} PRIVATE
             LOAD_ANI
+            SAVE_ANI=$<BOOL:${SDLIMAGE_ANI_SAVE}>
         )
     else()
         # Variable is used by test suite
diff --git a/include/SDL3_image/SDL_image.h b/include/SDL3_image/SDL_image.h
index df72e2fc..05213848 100644
--- a/include/SDL3_image/SDL_image.h
+++ b/include/SDL3_image/SDL_image.h
@@ -2605,6 +2605,14 @@ typedef struct IMG_AnimationEncoder IMG_AnimationEncoder;
 /**
  * Create an encoder to save a series of images to a file.
  *
+ * These animation types are currently supported:
+ *
+ * - ANI
+ * - APNG
+ * - AVIFS
+ * - GIF
+ * - WEBP
+ *
  * The file type is determined from the file extension, e.g. "file.webp" will
  * be encoded using WEBP.
  *
@@ -2624,6 +2632,14 @@ extern SDL_DECLSPEC IMG_AnimationEncoder * SDLCALL IMG_CreateAnimationEncoder(co
 /**
  * Create an encoder to save a series of images to an IOStream.
  *
+ * These animation types are currently supported:
+ *
+ * - ANI
+ * - APNG
+ * - AVIFS
+ * - GIF
+ * - WEBP
+ *
  * If `closeio` is true, `dst` will be closed before returning if this
  * function fails, or when the animation encoder is closed if this function
  * succeeds.
@@ -2647,6 +2663,14 @@ extern SDL_DECLSPEC IMG_AnimationEncoder * SDLCALL IMG_CreateAnimationEncoder_IO
 /**
  * Create an animation encoder with the specified properties.
  *
+ * These animation types are currently supported:
+ *
+ * - ANI
+ * - APNG
+ * - AVIFS
+ * - GIF
+ * - WEBP
+ *
  * These are the supported properties:
  *
  * - `IMG_PROP_ANIMATION_ENCODER_CREATE_FILENAME_STRING`: the file to save, if
@@ -2754,6 +2778,14 @@ typedef struct IMG_AnimationDecoder IMG_AnimationDecoder;
 /**
  * Create a decoder to read a series of images from a file.
  *
+ * These animation types are currently supported:
+ *
+ * - ANI
+ * - APNG
+ * - AVIFS
+ * - GIF
+ * - WEBP
+ *
  * The file type is determined from the file extension, e.g. "file.webp" will
  * be decoded using WEBP.
  *
@@ -2774,6 +2806,14 @@ extern SDL_DECLSPEC IMG_AnimationDecoder * SDLCALL IMG_CreateAnimationDecoder(co
 /**
  * Create a decoder to read a series of images from an IOStream.
  *
+ * These animation types are currently supported:
+ *
+ * - ANI
+ * - APNG
+ * - AVIFS
+ * - GIF
+ * - WEBP
+ *
  * If `closeio` is true, `src` will be closed before returning if this
  * function fails, or when the animation decoder is closed if this function
  * succeeds.
@@ -2798,6 +2838,14 @@ extern SDL_DECLSPEC IMG_AnimationDecoder * SDLCALL IMG_CreateAnimationDecoder_IO
 /**
  * Create an animation decoder with the specified properties.
  *
+ * These animation types are currently supported:
+ *
+ * - ANI
+ * - APNG
+ * - AVIFS
+ * - GIF
+ * - WEBP
+ *
  * These are the supported properties:
  *
  * - `IMG_PROP_ANIMATION_DECODER_CREATE_FILENAME_STRING`: the file to load, if
diff --git a/src/IMG_ani.c b/src/IMG_ani.c
index 476b36ab..f80cb2ab 100644
--- a/src/IMG_ani.c
+++ b/src/IMG_ani.c
@@ -22,8 +22,14 @@
 #include <SDL3_image/SDL_image.h>
 
 #include "IMG_ani.h"
+#include "IMG_anim_encoder.h"
 #include "IMG_anim_decoder.h"
 
+// We will have the saving ANI feature by default
+#if !defined(SAVE_ANI)
+#define SAVE_ANI 1
+#endif
+
 #ifdef LOAD_ANI
 
 #define RIFF_FOURCC(c0, c1, c2, c3)                 \
@@ -480,3 +486,218 @@ bool IMG_CreateANIAnimationDecoder(IMG_AnimationDecoder *decoder, SDL_Properties
 
 #endif // LOAD_ANI
 
+#if SAVE_ANI
+
+struct IMG_AnimationEncoderContext
+{
+    char *author;
+    char *title;
+    int num_frames;
+    int max_frames;
+    SDL_Surface **frames;
+    Uint64 *durations;
+};
+
+
+static bool AnimationEncoder_AddFrame(IMG_AnimationEncoder *encoder, SDL_Surface *surface, Uint64 duration)
+{
+    IMG_AnimationEncoderContext *ctx = encoder->ctx;
+
+    if (ctx->num_frames == ctx->max_frames) {
+        int max_frames = ctx->max_frames + 8;
+
+        SDL_Surface **frames = (SDL_Surface **)SDL_realloc(ctx->frames, max_frames * sizeof(*frames));
+        if (!frames) {
+            return false;
+        }
+
+        Uint64 *durations = (Uint64 *)SDL_realloc(ctx->durations, max_frames * sizeof(*durations));
+        if (!durations) {
+            SDL_free(frames);
+            return false;
+        }
+
+        ctx->frames = frames;
+        ctx->durations = durations;
+        ctx->max_frames = max_frames;
+    }
+
+    ctx->frames[ctx->num_frames] = surface;
+    ++surface->refcount;
+    ctx->durations[ctx->num_frames] = duration;
+    ++ctx->num_frames;
+
+    return true;
+}
+
+static bool SaveChunkSize(SDL_IOStream *dst, Sint64 offset)
+{
+    Sint64 here = SDL_TellIO(dst);
+    if (here < 0) {
+        return false;
+    }
+    if (SDL_SeekIO(dst, offset, SDL_IO_SEEK_SET) < 0) {
+        return false;
+    }
+
+    Uint32 size = (Uint32)(here - (offset + sizeof(Uint32)));
+    if (!SDL_WriteU32LE(dst, size)) {
+        return false;
+    }
+    return SDL_SeekIO(dst, here, SDL_IO_SEEK_SET);
+}
+
+static bool WriteIconFrame(SDL_Surface *surface, SDL_IOStream *dst)
+{
+    bool result = true;
+
+    result &= SDL_WriteU32LE(dst, RIFF_FOURCC('i', 'c', 'o', 'n'));
+    Sint64 icon_size_offset = SDL_TellIO(dst);
+    result &= SDL_WriteU32LE(dst, 0);
+    // Technically this could be ICO format, but it's generally animated cursors
+    result &= IMG_SaveCUR_IO(surface, dst, false);
+    result &= SaveChunkSize(dst, icon_size_offset);
+
+    return result;
+}
+
+static bool WriteAnimInfo(IMG_AnimationEncoderContext *ctx, SDL_IOStream *dst)
+{
+    bool result = true;
+
+    result &= SDL_WriteU32LE(dst, RIFF_FOURCC('L', 'I', 'S', 'T'));
+    Sint64 list_size_offset = SDL_TellIO(dst);
+    result &= SDL_WriteU32LE(dst, 0);
+    result &= SDL_WriteU32LE(dst, RIFF_FOURCC('I', 'N', 'F', 'O'));
+
+    if (ctx->title) {
+        Uint32 size = (Uint32)(SDL_strlen(ctx->title) + 1);
+        result &= SDL_WriteU32LE(dst, RIFF_FOURCC('I', 'N', 'A', 'M'));
+        result &= SDL_WriteU32LE(dst, size);
+        result &= (SDL_WriteIO(dst, ctx->title, size) == size);
+    }
+
+    if (ctx->author) {
+        Uint32 size = (Uint32)(SDL_strlen(ctx->author) + 1);
+        result &= SDL_WriteU32LE(dst, RIFF_FOURCC('I', 'A', 'R', 'T'));
+        result &= SDL_WriteU32LE(dst, size);
+        result &= (SDL_WriteIO(dst, ctx->author, size) == size);
+    }
+
+    result &= SaveChunkSize(dst, list_size_offset);
+
+    return result;
+}
+
+static bool WriteAnimation(IMG_AnimationEncoder *encoder)
+{
+    IMG_AnimationEncoderContext *ctx = encoder->ctx;
+    SDL_IOStream *dst = encoder->dst;
+    bool result = true;
+
+    // RIFF header
+    result &= SDL_WriteU32LE(dst, RIFF_FOURCC('R', 'I', 'F', 'F'));
+    Sint64 riff_size_offset = SDL_TellIO(dst);
+    result &= SDL_WriteU32LE(dst, 0);
+    result &= SDL_WriteU32LE(dst, RIFF_FOURCC('A', 'C', 'O', 'N'));
+
+    // anih header chunk
+    result &= SDL_WriteU32LE(dst, RIFF_FOURCC('a', 'n', 'i', 'h'));
+    result &= SDL_WriteU32LE(dst, sizeof(ANIHEADER));
+
+    ANIHEADER anih;
+    SDL_zero(anih);
+    anih.cbSizeof = sizeof(anih);
+    anih.frames = ctx->num_frames;
+    anih.steps = ctx->num_frames;
+    anih.jifRate = 1;
+    anih.fl = ANI_FLAG_ICON;
+    result &= (SDL_WriteIO(dst, &anih, sizeof(anih)) == sizeof(anih));
+
+    // Info list
+    if (ctx->author || ctx->title) {
+        WriteAnimInfo(ctx, dst);
+    }
+
+    // Rate chunk
+    result &= SDL_WriteU32LE(dst, RIFF_FOURCC('r', 'a', 't', 'e'));
+    result &= SDL_WriteU32LE(dst, sizeof(Uint32) * ctx->num_frames);
+    for (int i = 0; i < ctx->num_frames; ++i) {
+        Uint32 duration = (Uint32)IMG_GetEncoderDuration(encoder, ctx->durations[i], 60);
+        result &= SDL_WriteU32LE(dst, duration);
+    }
+
+    // Frame list
+    result &= SDL_WriteU32LE(dst, RIFF_FOURCC('L', 'I', 'S', 'T'));
+    Sint64 frame_list_size_offset = SDL_TellIO(dst);
+    result &= SDL_WriteU32LE(dst, 0);
+    result &= SDL_WriteU32LE(dst, RIFF_FOURCC('f', 'r', 'a', 'm'));
+
+    for (int i = 0; i < ctx->num_frames; ++i) {
+        result &= WriteIconFrame(ctx->frames[i], dst);
+    }
+    result &= SaveChunkSize(dst, frame_list_size_offset);
+
+    // All done!
+    result &= SaveChunkSize(dst, riff_size_offset);
+
+    return result;
+}
+
+static bool AnimationEncoder_End(IMG_AnimationEncoder *encoder)
+{
+    IMG_AnimationEncoderContext *ctx = encoder->ctx;
+    bool result = true;
+
+    if (ctx->num_frames > 0) {
+        result = WriteAnimation(encoder);
+
+        for (int i = 0; i < ctx->num_frames; ++i) {
+            SDL_DestroySurface(ctx->frames[i]);
+        }
+        SDL_free(ctx->frames);
+        SDL_free(ctx->durations);
+    }
+
+    SDL_free(ctx->author);
+    SDL_free(ctx->title);
+    SDL_free(ctx);
+    encoder->ctx = NULL;
+
+    return result;
+}
+
+bool IMG_CreateANIAnimationEncoder(IMG_AnimationEncoder *encoder, SDL_PropertiesID props)
+{
+    IMG_AnimationEncoderContext *ctx;
+
+    ctx = (IMG_AnimationEncoderContext *)SDL_calloc(1, sizeof(IMG_AnimationEncoderContext));
+    if (!ctx) {
+        return false;
+    }
+
+    const char *author = SDL_GetStringProperty(props, IMG_PROP_METADATA_AUTHOR_STRING, NULL);
+    if (author && *author) {
+        ctx->author = SDL_strdup(author);
+    }
+
+    const char *title = SDL_GetStringProperty(props, IMG_PROP_METADATA_TITLE_STRING, NULL);
+    if (title && *title) {
+        ctx->title = SDL_strdup(title);
+    }
+
+    encoder->ctx = ctx;
+    encoder->AddFrame = AnimationEncoder_AddFrame;
+    encoder->Close = AnimationEncoder_End;
+
+    return true;
+}
+
+#else
+
+bool IMG_CreateANIAnimationEncoder(IMG_AnimationEncoder *encoder, SDL_PropertiesID props)
+{
+    return SDL_SetError("SDL_image built without ANI save support");
+}
+
+#endif // SAVE_ANI
diff --git a/src/IMG_ani.h b/src/IMG_ani.h
index 45eb3abc..3197484b 100644
--- a/src/IMG_ani.h
+++ b/src/IMG_ani.h
@@ -19,5 +19,6 @@
   3. This notice may not be removed or altered from any source distribution.
 */
 
+extern bool IMG_CreateANIAnimationEncoder(IMG_AnimationEncoder *encoder, SDL_PropertiesID props);
 extern bool IMG_CreateANIAnimationDecoder(IMG_AnimationDecoder *decoder, SDL_PropertiesID props);
 
diff --git a/src/IMG_anim_decoder.c b/src/IMG_anim_decoder.c
index 72958b2d..c024f035 100644
--- a/src/IMG_anim_decoder.c
+++ b/src/IMG_anim_decoder.c
@@ -22,11 +22,11 @@
 #include <SDL3_image/SDL_image.h>
 
 #include "IMG_anim_decoder.h"
-#include "IMG_webp.h"
-#include "IMG_libpng.h"
-#include "IMG_gif.h"
-#include "IMG_avif.h"
 #include "IMG_ani.h"
+#include "IMG_avif.h"
+#include "IMG_gif.h"
+#include "IMG_libpng.h"
+#include "IMG_webp.h"
 
 IMG_AnimationDecoder *IMG_CreateAnimationDecoder(const char *file)
 {
@@ -139,16 +139,16 @@ IMG_AnimationDecoder *IMG_CreateAnimationDecoderWithProperties(SDL_PropertiesID
     }
 
     bool result = false;
-    if (SDL_strcasecmp(type, "webp") == 0) {
-        result = IMG_CreateWEBPAnimationDecoder(decoder, props);
-    } else if (SDL_strcasecmp(type, "png") == 0) {
+    if (SDL_strcasecmp(type, "ani") == 0) {
+        result = IMG_CreateANIAnimationDecoder(decoder, props);
+    } else if (SDL_strcasecmp(type, "apng") == 0 || SDL_strcasecmp(type, "png") == 0) {
         result = IMG_CreateAPNGAnimationDecoder(decoder, props);
-    } else if (SDL_strcasecmp(type, "gif") == 0) {
-        result = IMG_CreateGIFAnimationDecoder(decoder, props);
     } else if (SDL_strcasecmp(type, "avifs") == 0) {
         result = IMG_CreateAVIFAnimationDecoder(decoder, props);
-    } else if (SDL_strcasecmp(type, "ani") == 0) {
-        result = IMG_CreateANIAnimationDecoder(decoder, props);
+    } else if (SDL_strcasecmp(type, "gif") == 0) {
+        result = IMG_CreateGIFAnimationDecoder(decoder, props);
+    } else if (SDL_strcasecmp(type, "webp") == 0) {
+        result = IMG_CreateWEBPAnimationDecoder(decoder, props);
     } else {
         SDL_SetError("Unrecognized output type");
     }
diff --git a/src/IMG_anim_encoder.c b/src/IMG_anim_encoder.c
index 83aeb69c..ceec0056 100644
--- a/src/IMG_anim_encoder.c
+++ b/src/IMG_anim_encoder.c
@@ -22,10 +22,12 @@
 #include <SDL3_image/SDL_image.h>
 
 #include "IMG_anim_encoder.h"
-#include "IMG_webp.h"
-#include "IMG_libpng.h"
-#include "IMG_gif.h"
+#include "IMG_ani.h"
 #include "IMG_avif.h"
+#include "IMG_gif.h"
+#include "IMG_libpng.h"
+#include "IMG_webp.h"
+
 
 IMG_AnimationEncoder *IMG_CreateAnimationEncoder(const char *file)
 {
@@ -134,14 +136,16 @@ IMG_AnimationEncoder *IMG_CreateAnimationEncoderWithProperties(SDL_PropertiesID
     encoder->timebase_denominator = timebase_denominator;
 
     bool result = false;
-    if (SDL_strcasecmp(type, "webp") == 0) {
-        result = IMG_CreateWEBPAnimationEncoder(encoder, props);
-    } else if (SDL_strcasecmp(type, "png") == 0) {
+    if (SDL_strcasecmp(type, "ani") == 0) {
+        result = IMG_CreateANIAnimationEncoder(encoder, props);
+    } else if (SDL_strcasecmp(type, "apng") == 0 || SDL_strcasecmp(type, "png") == 0) {
         result = IMG_CreateAPNGAnimationEncoder(encoder, props);
-    } else if (SDL_strcasecmp(type, "gif") == 0) {
-        result = IMG_CreateGIFAnimationEncoder(encoder, props);
     } else if (SDL_strcasecmp(type, "avifs") == 0) {
         result = IMG_CreateAVIFAnimationEncoder(encoder, props);
+    } else if (SDL_strcasecmp(type, "gif") == 0) {
+        result = IMG_CreateGIFAnimationEncoder(encoder, props);
+    } else if (SDL_strcasecmp(type, "webp") == 0) {
+        result = IMG_CreateWEBPAnimationEncoder(encoder, props);
     } else {
         SDL_SetError("Unrecognized output type");
     }
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index e90d8c75..a28ca137 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -66,11 +66,15 @@ function(add_sdl_image_test NAME)
         "SDL_TEST_SRCDIR=${CMAKE_CURRENT_SOURCE_DIR}"
         "SDL_TEST_BUILDDIR=$<TARGET_FILE_DIR:${TARGET}>"
         "SDL_VIDEO_DRIVER=dummy"
+        "SDL_IMAGE_SAVE_ANI=$<BOOL:${SDLIMAGE_ANI_SAVE}>"
         "SDL_IMAGE_SAVE_AVIF=$<BOOL:${SDLIMAGE_AVIF_SAVE}>"
+        "SDL_IMAGE_SAVE_CUR=$<BOOL:${SDLIMAGE_BMP_SAVE}>"
+        "SDL_IMAGE_SAVE_ICO=$<BOOL:${SDLIMAGE_BMP_SAVE}>"
         "SDL_IMAGE_SAVE_JPG=$<BOOL:${SDLIMAGE_JPG_SAVE}>"
         "SDL_IMAGE_SAVE_PNG=$<BOOL:${SDLIMAGE_PNG_SAVE}>"
+        "SDL_IMAGE_ANIM_ANI=$<BOOL:${SDLIMAGE_ANI_ENABLED}>"
+        "SDL_IMAGE_ANIM_APNG=$<AND:$<BOOL:${SDLIMAGE_PNG_ENABLED}>,$<NOT:$<OR:$<BOOL:${SDLIMAGE_BACKEND_WIC}>,$<BOOL:${SDLIMAGE_BACKEND_STB}>,$<BOOL:${SDLIMAGE_BACKEND_IMAGEIO}>>>>"
         "SDL_IMAGE_ANIM_AVIFS=$<BOOL:${SDLIMAGE_AVIF_ENABLED}>"
-        "SDL_IMAGE_ANIM_PNG=$<AND:$<BOOL:${SDLIMAGE_PNG_ENABLED}>,$<NOT:$<OR:$<BOOL:${SDLIMAGE_BACKEND_WIC}>,$<BOOL:${SDLIMAGE_BACKEND_STB}>,$<BOOL:${SDLIMAGE_BACKEND_IMAGEIO}>>>>"
         "SDL_IMAGE_ANIM_GIF=$<BOOL:${SDLIMAGE_GIF_ENABLED}>"
         "SDL_IMAGE_ANIM_WEBP=$<BOOL:${SDLIMAGE_WEBP_ENABLED}>"
     )
diff --git a/test/rgbrgb.ani b/test/rgbrgb.ani
new file mode 100755
index 0000000000000000000000000000000000000000..289156c607ec0074128d1a54ad9fd2ad7a5e5d6b
GIT binary patch
literal 5404
zcmWIYbaN9CWngf0_V-K7%gj(=U|;}YHYf&CD1Z^j1L2~?l2i#O8zu&#nV@1mp1~n|
zL>L&-iV|}(lk@X(n1K>NS&$kC2m^|U0hyfve(t<nQd~eLucwDg5X5{&1`d!DVv1Iz
zF)%R8db&7<RLpsM#gLIff#<-6#`^QlZ0^FWd#lRQ*gbabXKtwHVK_93Mnhl(hJf7K
zZ_NL0{4ZY*%2%GQelF{r5}Ih4=hD(g^W11*GonL4Zq0Y*e=4t3kI^m9m5t`P5uM^j
zV8w>!Wx3TqnE%;-+EYTWJU805M2mq@?nn!P(YEDiw{kQD=o<o~ZA<!w_ed+#NBxT#
I0$AIY0NSB|m;e9(

literal 0
HcmV?d00001

diff --git a/test/testanimation.c b/test/testanimation.c
index a610b6f1..36448af7 100644
--- a/test/testanimation.c
+++ b/test/testanimation.c
@@ -45,13 +45,15 @@ static const Sint64 default_loop_count = 1;
 #define DEFAULT_COPYRIGHT     "quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat"
 
 static const FormatInfo formatInfo[] = {
-    { "PNG", { { IMG_PROP_METADATA_TITLE_STRING, (const void*)DEFAULT_TITLE, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_AUTHOR_STRING, (const void*)DEFAULT_AUTHOR, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_DESCRIPTION_STRING, (const void*)DEFAULT_DESCRIPTION, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_LOOP_COUNT_NUMBER, (const void *)&default_loop_count, SDL_PROPERTY_TYPE_NUMBER }, { IMG_PROP_METADATA_CREATION_TIME_STRING, (const void*)DEFAULT_CREATION_TIME, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_COPYRIGHT_STRING, (const void*)DEFAULT_COPYRIGHT, SDL_PROPERTY_TYPE_STRING } } },
+    { "ANI", { { IMG_PROP_METADATA_TITLE_STRING, (const void*)DEFAULT_TITLE, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_AUTHOR_STRING, (const void*)DEFAULT_AUTHOR, SDL_PROPERTY_TYPE_STRING } } },
+
+    { "APNG", { { IMG_PROP_METADATA_TITLE_STRING, (const void*)DEFAULT_TITLE, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_AUTHOR_STRING, (const void*)DEFAULT_AUTHOR, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_DESCRIPTION_STRING, (const void*)DEFAULT_DESCRIPTION, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_LOOP_COUNT_NUMBER, (const void *)&default_loop_count, SDL_PROPERTY_TYPE_NUMBER }, { IMG_PROP_METADATA_CREATION_TIME_STRING, (const void*)DEFAULT_CREATION_TIME, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_COPYRIGHT_STRING, (const void*)DEFAULT_COPYRIGHT, SDL_PROPERTY_TYPE_STRING } } },
+
+    { "AVIFS", { { IMG_PROP_METADATA_TITLE_STRING, (const void*)DEFAULT_TITLE, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_AUTHOR_STRING, (const void*)DEFAULT_AUTHOR, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_DESCRIPTION_STRING, (const void*)DEFAULT_DESCRIPTION, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_LOOP_COUNT_NUMBER, (const void *)&default_loop_count, SDL_PROPERTY_TYPE_NUMBER }, { IMG_PROP_METADATA_CREATION_TIME_STRING, (const void*)DEFAULT_CREATION_TIME, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_COPYRIGHT_STRING, (const void*)DEFAULT_COPYRIGHT, SDL_PROPERTY_TYPE_STRING } } },
 
     { "GIF", { { IMG_PROP_METADATA_DESCRIPTION_STRING, (const void*)DEFAULT_DESCRIPTION, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_LOOP_COUNT_NUMBER, (const void *)&default_loop_count, SDL_PROPERTY_TYPE_NUMBER } } },
 
     { "WEBP", { { IMG_PROP_METADATA_TITLE_STRING, (const void*)DEFAULT_TITLE, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_AUTHOR_STRING, (const void*)DEFAULT_AUTHOR, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_DESCRIPTION_STRING, (const void*)DEFAULT_DESCRIPTION, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_LOOP_COUNT_NUMBER, (const void *)&default_loop_count, SDL_PROPERTY_TYPE_NUMBER }, { IMG_PROP_METADATA_CREATION_TIME_STRING, (const void*)DEFAULT_CREATION_TIME, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_COPYRIGHT_STRING, (const void*)DEFAULT_COPYRIGHT, SDL_PROPERTY_TYPE_STRING } } },
-
-    { "AVIFS", { { IMG_PROP_METADATA_TITLE_STRING, (const void*)DEFAULT_TITLE, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_AUTHOR_STRING, (const void*)DEFAULT_AUTHOR, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_DESCRIPTION_STRING, (const void*)DEFAULT_DESCRIPTION, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_LOOP_COUNT_NUMBER, (const void *)&default_loop_count, SDL_PROPERTY_TYPE_NUMBER }, { IMG_PROP_METADATA_CREATION_TIME_STRING, (const void*)DEFAULT_CREATION_TIME, SDL_PROPERTY_TYPE_STRING }, { IMG_PROP_METADATA_COPYRIGHT_STRING, (const void*)DEFAULT_COPYRIGHT, SDL_PROPERTY_TYPE_STRING } } },
 };
 
 
@@ -59,12 +61,13 @@ static const struct {
     const char *format;
     const char *filename;
 } inputImages[] = {
-    { "PNG", "rgbrgb.png" },
+    { "ANI", "rgbrgb.ani" },
+    { "APNG", "rgbrgb.png" },
+    { "AVIFS", "rgbrgb.avifs" },
     { "GIF", "rgbrgb.gif" },
-    { "WEBP", "rgbrgb.webp" },
-    { "AVIFS", "rgbrgb.avifs" }
+    { "WEBP", "rgbrgb.webp" }
 };
-static const char *outputImageFormats[] = { "PNG", "GIF", "WEBP", "AVIFS" };
+static const char *outputImageFormats[] = { "ANI", "APNG", "AVIFS", "GIF", "WEBP" };
 
 static const char *GetAnimationDecoderStatusString(IMG_AnimationDecoderStatus status)
 {
@@ -341,7 +344,7 @@ static int SDLCALL testDecodeEncode(void *args) {
                     SDLTest_AssertPass("Encoder closed successfully after adding %i frames.", ii);
                 }
 
-                SDLTest_AssertCheck(ii == i, "All frames were added teo the encoder. Added %i, Total: %i", ii, i);
+                SDLTest_AssertCheck(ii == i, "All frames were added to the encoder. Added %i, Total: %i", ii, i);
                 if (ii != i) {
                     for (int ai = 0; ai < arraySize; ++ai) {
                         SDL_free(decodedFrameData[ai]);
@@ -375,10 +378,16 @@ static int SDLCALL testDecodeEncode(void *args) {
                                                                                    outputImageFormat);
                     SDLTest_AssertCheck(decoder2 != NULL, "IMG_CreateAnimationDecoder_IO");
                     if (decoder2) {
-
+                        bool check_duration = true;
                         Uint64 duration2;
                         SDL_Surface *frame2;
                         int j = 0;
+                        if (SDL_strcmp(inputImages[cim].format, "ANI") == 0  || SDL_strcmp(outputImageFormat, "ANI") == 0) {
+                            /* ANI uses 1/60 time units which can't represent our 20 ms sample frame durations.
+                             * If we switched the sample data to be 100 FPS, that would work for both, but as-is...
+                             */
+                            check_duration = false;
+                        }
                         while (IMG_GetAnimationDecoderFrame(decoder2, &frame2, &duration2)) {
                             SDLTest_Log("Reloaded Frame Duration (%i): %" SDL_PRIu64 " ms", j, duration2);
                             SDLTest_Log("Reloaded Frame Format (%i): %s", j, SDL_GetPixelFormatName(frame2->format));
@@ -398,7 +407,7 @@ static int SDLCALL testDecodeEncode(void *args) {
 
                             if (decodedFrameData[j]->width != frame2->w ||
                                 decodedFrameData[j]->height != frame2->h ||
-                                decodedFrameData[j]->duration != duration2) {
+                                (check_duration && decodedFrameData[j]->duration != duration2)) {
                                 SDLTest_LogError("Frame data mismatch at index %i. Expected (%i, %i, %" SDL_PRIu64 "), Got (%i, %i, %" SDL_PRIu64 ")",
                                     j, decodedFrameData[j]->width, decodedFrameData[j]->height, decodedFrameData[j]->duration, frame2->w, frame2->h, duration2);
                                 for (int ai = 0; ai < arraySize; ++ai) {