diff --git a/src/KappaDuck.Quack/Graphics/Drawing/AnimatedSprite.cs b/src/KappaDuck.Quack/Graphics/Drawing/AnimatedSprite.cs
new file mode 100644
index 0000000..aacd14e
--- /dev/null
+++ b/src/KappaDuck.Quack/Graphics/Drawing/AnimatedSprite.cs
@@ -0,0 +1,131 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+using KappaDuck.Quack.Geometry;
+using KappaDuck.Quack.Graphics.Rendering;
+
+namespace KappaDuck.Quack.Graphics.Drawing;
+
+///
+/// A that plays a from a , cycling its
+/// displayed frame over time.
+///
+///
+/// Choose a clip with , advance it with
+/// once per frame, then draw it like any drawable with . Flip the sprite
+/// with a negative . is idempotent, so calling it
+/// every frame while a key is held keeps the animation running rather than restarting it.
+///
+///
+/// Creates an animated sprite that draws frames from the given sheet.
+///
+/// The sheet the animation frames are taken from.
+public sealed class AnimatedSprite(SpriteSheet sheet)
+ : Sprite(sheet.Texture, sheet.Count > 0 ? sheet[0] : new RectI(0, 0, sheet.Texture.Width, sheet.Texture.Height))
+{
+ private TimeSpan _elapsed;
+ private int _frameIndex;
+
+ ///
+ /// Gets or sets the sheet the animation frames are taken from.
+ ///
+ public SpriteSheet Sheet
+ {
+ get;
+ set
+ {
+ field = value;
+ Texture = value.Texture;
+
+ UpdateRegion();
+ }
+ } = sheet;
+
+ ///
+ /// Gets the animation currently playing, or if none has been played yet.
+ ///
+ public SpriteAnimation? Animation { get; private set; }
+
+ ///
+ /// Gets a value indicating whether an animation is currently playing.
+ ///
+ public bool IsPlaying { get; private set; }
+
+ ///
+ /// Plays an animation from its first frame, or does nothing if it is already the animation playing.
+ ///
+ /// The animation to play.
+ public void Play(SpriteAnimation animation)
+ {
+ if (ReferenceEquals(Animation, animation) && IsPlaying)
+ return;
+
+ Animation = animation;
+ _elapsed = TimeSpan.Zero;
+ _frameIndex = 0;
+ IsPlaying = true;
+
+ UpdateRegion();
+ }
+
+ ///
+ /// Stops the animation and rewinds it to its first frame.
+ ///
+ public void Stop()
+ {
+ IsPlaying = false;
+ _elapsed = TimeSpan.Zero;
+ _frameIndex = 0;
+
+ UpdateRegion();
+ }
+
+ ///
+ /// Pauses the animation on its current frame.
+ ///
+ public void Pause() => IsPlaying = false;
+
+ ///
+ /// Resumes a paused animation from its current frame.
+ ///
+ public void Resume() => IsPlaying = Animation is not null;
+
+ ///
+ /// Advances the current animation by the elapsed time.
+ ///
+ /// The time since the last update, such as the frame delta.
+ public void Update(TimeSpan deltaTime)
+ {
+ if (!IsPlaying || Animation is null)
+ return;
+
+ _elapsed += deltaTime;
+
+ while (_elapsed >= Animation.DurationAt(_frameIndex))
+ {
+ _elapsed -= Animation.DurationAt(_frameIndex);
+ _frameIndex++;
+
+ if (_frameIndex < Animation.FrameCount)
+ continue;
+
+ if (Animation.Loop)
+ {
+ _frameIndex = 0;
+ continue;
+ }
+
+ _frameIndex = Animation.FrameCount - 1;
+ IsPlaying = false;
+ break;
+ }
+
+ UpdateRegion();
+ }
+
+ private void UpdateRegion()
+ {
+ if (Animation is not null)
+ Region = Sheet[Animation.FrameAt(_frameIndex)];
+ }
+}
diff --git a/src/KappaDuck.Quack/Graphics/Drawing/SpriteAnimation.cs b/src/KappaDuck.Quack/Graphics/Drawing/SpriteAnimation.cs
new file mode 100644
index 0000000..a670762
--- /dev/null
+++ b/src/KappaDuck.Quack/Graphics/Drawing/SpriteAnimation.cs
@@ -0,0 +1,82 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+namespace KappaDuck.Quack.Graphics.Drawing;
+
+///
+/// A reusable animation clip: a sequence of frame indices, each shown for a duration, with
+/// an optional loop.
+///
+///
+/// A clip is independent of any particular sprite; the same clip can be played by several
+/// instances. Frame indices refer to the sheet the animation is played on.
+///
+public sealed class SpriteAnimation
+{
+ private readonly int[] _frames;
+ private readonly TimeSpan[] _durations;
+
+ ///
+ /// Creates an animation whose frames are all shown for the same duration.
+ ///
+ /// The sheet frame indices, in playback order.
+ /// How long each frame is shown.
+ /// to restart from the first frame after the last; otherwise .
+ public SpriteAnimation(ReadOnlySpan frames, TimeSpan frameDuration, bool loop = true)
+ {
+ _frames = frames.ToArray();
+ _durations = new TimeSpan[_frames.Length];
+
+ Array.Fill(_durations, frameDuration);
+
+ Loop = loop;
+ Duration = frameDuration * _frames.Length;
+ }
+
+ ///
+ /// Creates an animation with a separate duration for each frame.
+ ///
+ /// The sheet frame indices, in playback order.
+ /// How long each corresponding frame is shown. Must have the same length as .
+ /// to restart from the first frame after the last; otherwise .
+ /// and have different lengths.
+ public SpriteAnimation(ReadOnlySpan frames, ReadOnlySpan durations, bool loop = true)
+ {
+ ArgumentOutOfRangeException.ThrowIfNotEqual(frames.Length, durations.Length);
+
+ _frames = frames.ToArray();
+ _durations = durations.ToArray();
+
+ Loop = loop;
+ Duration = Sum(durations);
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the animation restarts after its last frame.
+ ///
+ public bool Loop { get; set; }
+
+ ///
+ /// Gets the number of frames in the animation.
+ ///
+ public int FrameCount => _frames.Length;
+
+ ///
+ /// Gets the total time for one pass through every frame.
+ ///
+ public TimeSpan Duration { get; }
+
+ internal int FrameAt(int index) => _frames[index];
+
+ internal TimeSpan DurationAt(int index) => _durations[index];
+
+ private static TimeSpan Sum(ReadOnlySpan durations)
+ {
+ TimeSpan total = TimeSpan.Zero;
+
+ foreach (TimeSpan duration in durations)
+ total += duration;
+
+ return total;
+ }
+}
diff --git a/src/KappaDuck.Quack/Graphics/Drawing/SpriteAtlas.cs b/src/KappaDuck.Quack/Graphics/Drawing/SpriteAtlas.cs
new file mode 100644
index 0000000..6d7597c
--- /dev/null
+++ b/src/KappaDuck.Quack/Graphics/Drawing/SpriteAtlas.cs
@@ -0,0 +1,185 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+using KappaDuck.Quack.Exceptions;
+using KappaDuck.Quack.Geometry;
+using KappaDuck.Quack.Graphics.Rendering;
+using KappaDuck.Quack.Video.Pixels;
+
+namespace KappaDuck.Quack.Graphics.Drawing;
+
+///
+/// A set of pages divided into frames, addressed by index.
+///
+///
+/// The multi-page counterpart to : where a sheet always packs frames into exactly one
+/// texture and fails if they don't fit, an atlas spreads them across as many pages as needed, so animations with a
+/// large frame count or large frame dimensions can still be loaded with .
+/// The trade-off is that consecutive frames can land on different pages, so drawing across a page boundary costs a
+/// texture rebind. Animations that comfortably fit one texture, prefer .
+///
+public sealed class SpriteAtlas : IDisposable
+{
+ private readonly Texture[] _pages;
+ private readonly (int Page, RectI Region)[] _frames;
+ private bool _disposed;
+
+ private SpriteAtlas(List pages, (int Page, RectI Region)[] frames)
+ {
+ _pages = [.. pages];
+ _frames = frames;
+ }
+
+ ///
+ /// Gets every texture page. Frames in reference these by index.
+ ///
+ public IReadOnlyList Pages => _pages;
+
+ ///
+ /// Gets the number of frames in the atlas.
+ ///
+ public int Count => _frames.Length;
+
+ ///
+ /// Gets the page and source rectangle of a frame, in that page's texture pixels.
+ ///
+ /// The frame index, from 0 to minus one.
+ /// The page the frame is packed on, and its source rectangle within that page.
+ public (Texture Texture, RectI Region) this[int index]
+ {
+ get
+ {
+ (int page, RectI region) = _frames[index];
+ return (_pages[page], region);
+ }
+ }
+
+ ///
+ /// Loads an animated image (such as a GIF, APNG or animated WebP) into a sprite atlas and a matching animation.
+ ///
+ ///
+ /// Frames are packed into as few pages as possible: each page is filled as a grid bounded by
+ /// before a new one is started, so a page is only ever as large as the frames
+ /// it actually holds. Every returned texture is created here and owned by the caller;
+ /// disposing the atlas disposes every page. The returned animation uses each frame's own duration and loops.
+ ///
+ /// The renderer used to create the packed textures.
+ /// The path to the animated image file.
+ ///
+ /// The maximum width and height, in pixels, of each packed page. 4096 is a conservative default that fits within
+ /// common GPU limits; raise it if the target hardware supports larger textures.
+ ///
+ /// The packed atlas and an animation that plays its frames in order.
+ /// The file does not exist.
+ /// is negative or zero.
+ /// A single frame is larger than in either dimension.
+ /// The animation could not be loaded.
+ public static (SpriteAtlas Atlas, SpriteAnimation Animation) LoadAnimation(Renderer renderer, string path, int maxAtlasWidth = 4096)
+ {
+ using Animation animation = Animation.Load(path);
+ return Pack(renderer, animation, maxAtlasWidth);
+ }
+
+ ///
+ /// Loads an animated image from a stream into a sprite atlas and a matching animation.
+ ///
+ ///
+ /// Frames are packed into as few pages as possible: each page is filled as a grid bounded by
+ /// before a new one is started, so a page is only ever as large as the frames
+ /// it actually holds. Every returned texture is created here and owned by the caller;
+ /// disposing the atlas disposes every page. The returned animation uses each frame's own duration and loops.
+ ///
+ /// The renderer used to create the packed textures.
+ /// The stream to read the animated image from.
+ ///
+ /// The maximum width and height, in pixels, of each packed page. 4096 is a conservative default that fits within
+ /// common GPU limits; raise it if the target hardware supports larger textures.
+ ///
+ /// The packed atlas and an animation that plays its frames in order.
+ /// is negative or zero.
+ /// A single frame is larger than in either dimension.
+ /// The animation could not be loaded.
+ public static (SpriteAtlas Atlas, SpriteAnimation Animation) LoadAnimation(Renderer renderer, Stream stream, int maxAtlasWidth = 4096)
+ {
+ using Animation animation = Animation.Load(stream);
+ return Pack(renderer, animation, maxAtlasWidth);
+ }
+
+ ///
+ /// Disposes every page in the atlas.
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+
+ foreach (Texture page in _pages)
+ page.Dispose();
+ }
+
+ private static (SpriteAtlas Atlas, SpriteAnimation Animation) Pack(Renderer renderer, Animation animation, int maxAtlasWidth)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxAtlasWidth);
+
+ int frameWidth = animation.Width;
+ int frameHeight = animation.Height;
+ int count = animation.FrameCount;
+
+ if (frameWidth > maxAtlasWidth || frameHeight > maxAtlasWidth)
+ {
+ ThrowHelper.ThrowInvalidOperation($"This animation's frames are {frameWidth}x{frameHeight}, which alone exceeds the {maxAtlasWidth} maximum atlas dimension. Raise maxAtlasWidth if the target hardware supports a larger texture.");
+ }
+
+ int columns = Math.Max(1, maxAtlasWidth / frameWidth);
+ int rowsPerPage = Math.Max(1, maxAtlasWidth / frameHeight);
+ int framesPerPage = columns * rowsPerPage;
+
+ List pages = [];
+ (int Page, RectI Region)[] frames = new (int, RectI)[count];
+ TimeSpan[] durations = new TimeSpan[count];
+
+ int frameIndex = 0;
+
+ try
+ {
+ while (frameIndex < count)
+ {
+ int framesOnPage = Math.Min(framesPerPage, count - frameIndex);
+ int rows = (framesOnPage + columns - 1) / columns;
+
+ using Surface page = new(columns * frameWidth, rows * frameHeight, animation.Frames[0].Format);
+
+ for (int i = 0; i < framesOnPage; i++)
+ {
+ RectI region = new(i % columns * frameWidth, i / columns * frameHeight, frameWidth, frameHeight);
+
+ page.Blit(animation.Frames[frameIndex], destination: region);
+
+ frames[frameIndex] = (pages.Count, region);
+ durations[frameIndex] = animation.Delays[frameIndex];
+
+ frameIndex++;
+ }
+
+ pages.Add(Texture.FromSurface(renderer, page));
+ }
+ }
+ catch
+ {
+ foreach (Texture page in pages)
+ page.Dispose();
+
+ throw;
+ }
+
+ SpriteAtlas atlas = new(pages, frames);
+
+ int[] indexes = new int[count];
+ for (int i = 0; i < count; i++)
+ indexes[i] = i;
+
+ return (atlas, new SpriteAnimation(indexes, durations));
+ }
+}
diff --git a/src/KappaDuck.Quack/Graphics/Drawing/SpriteSheet.cs b/src/KappaDuck.Quack/Graphics/Drawing/SpriteSheet.cs
new file mode 100644
index 0000000..16fafc7
--- /dev/null
+++ b/src/KappaDuck.Quack/Graphics/Drawing/SpriteSheet.cs
@@ -0,0 +1,173 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+using KappaDuck.Quack.Exceptions;
+using KappaDuck.Quack.Geometry;
+using KappaDuck.Quack.Graphics.Rendering;
+using KappaDuck.Quack.Video.Pixels;
+
+namespace KappaDuck.Quack.Graphics.Drawing;
+
+///
+/// A divided into a set of frames, addressed by index.
+///
+///
+/// Build one from a uniform grid (the common case for character and tile sheets), from an explicit list of rectangles
+/// for irregular sheets, or from an animated image with . It is a
+/// lightweight view onto the texture; it does not own or dispose it, except a sheet produced by
+/// , whose texture the caller owns.
+///
+public sealed class SpriteSheet
+{
+ private readonly RectI[] _frames;
+
+ ///
+ /// Creates a sprite sheet from a uniform grid of frames.
+ ///
+ /// Frames are numbered left to right, then top to bottom. Any partial frame at the right or bottom edge is ignored.
+ /// The texture to slice.
+ /// The width of each frame, in texture pixels.
+ /// The height of each frame, in texture pixels.
+ /// or is negative or zero.
+ public SpriteSheet(Texture texture, int frameWidth, int frameHeight)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(frameWidth);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(frameHeight);
+
+ Texture = texture;
+
+ int columns = texture.Width / frameWidth;
+ int rows = texture.Height / frameHeight;
+
+ _frames = new RectI[columns * rows];
+
+ for (int row = 0; row < rows; row++)
+ {
+ for (int column = 0; column < columns; column++)
+ _frames[(row * columns) + column] = new RectI(column * frameWidth, row * frameHeight, frameWidth, frameHeight);
+ }
+ }
+
+ ///
+ /// Creates a sprite sheet from an explicit list of frame rectangles.
+ ///
+ /// The texture to slice.
+ /// The frame rectangles, in texture pixels.
+ public SpriteSheet(Texture texture, params ReadOnlySpan frames)
+ {
+ Texture = texture;
+ _frames = frames.ToArray();
+ }
+
+ ///
+ /// Gets the texture the frames are taken from.
+ ///
+ public Texture Texture { get; }
+
+ ///
+ /// Gets the number of frames in the sheet.
+ ///
+ public int Count => _frames.Length;
+
+ ///
+ /// Gets the source rectangle of a frame, in texture pixels.
+ ///
+ /// The frame index, from 0 to minus one.
+ /// The frame's source rectangle.
+ public RectI this[int index] => _frames[index];
+
+ ///
+ /// Loads an animated image (such as a GIF, APNG or animated WebP) into a sprite sheet and a matching animation.
+ ///
+ ///
+ /// Every frame is packed into a single texture — one texture bind for every sprite that plays this clip, instead
+ /// of one per frame. Frames are arranged in a grid bounded by rather than a single
+ /// row, so long or wide animations don't produce a texture wider than the GPU can allocate. The returned sheet's
+ /// is created here and is owned by the caller; dispose it when finished. The returned
+ /// animation uses each frame's own duration and loops.
+ ///
+ /// The renderer used to create the packed texture.
+ /// The path to the animated image file.
+ ///
+ /// The maximum width, in pixels, of the packed texture. 4096 is a conservative default that fits within common
+ /// GPU limits; raise it if the target hardware supports wider textures.
+ ///
+ /// The packed sheet and an animation that plays its frames in order.
+ /// The file does not exist.
+ /// is negative or zero.
+ /// The animation's frames don't fit in a single texture within .
+ /// The animation could not be loaded.
+ public static (SpriteSheet Sheet, SpriteAnimation Animation) LoadAnimation(Renderer renderer, string path, int maxAtlasWidth = 4096)
+ {
+ using Animation animation = Animation.Load(path);
+ return Pack(renderer, animation, maxAtlasWidth);
+ }
+
+ ///
+ /// Loads an animated image from a stream into a sprite sheet and a matching animation.
+ ///
+ ///
+ /// Every frame is packed into a single texture — one texture bind for every sprite that plays this clip, instead
+ /// of one per frame. Frames are arranged in a grid bounded by rather than a single
+ /// row, so long or wide animations don't produce a texture wider than the GPU can allocate. The returned sheet's
+ /// is created here and is owned by the caller; dispose it when finished. The returned
+ /// animation uses each frame's own duration and loops.
+ ///
+ /// The renderer used to create the packed texture.
+ /// The stream to read the animated image from.
+ ///
+ /// The maximum width, in pixels, of the packed texture. 4096 is a conservative default that fits within common
+ /// GPU limits; raise it if the target hardware supports wider textures.
+ ///
+ /// The packed sheet and an animation that plays its frames in order.
+ /// is negative or zero.
+ /// The animation's frames don't fit in a single texture within .
+ /// The animation could not be loaded.
+ public static (SpriteSheet Sheet, SpriteAnimation Animation) LoadAnimation(Renderer renderer, Stream stream, int maxAtlasWidth = 4096)
+ {
+ using Animation animation = Animation.Load(stream);
+ return Pack(renderer, animation, maxAtlasWidth);
+ }
+
+ private static (SpriteSheet Sheet, SpriteAnimation Animation) Pack(Renderer renderer, Animation animation, int maxAtlasWidth)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxAtlasWidth);
+
+ int frameWidth = animation.Width;
+ int frameHeight = animation.Height;
+ int count = animation.FrameCount;
+
+ int columns = Math.Clamp(maxAtlasWidth / frameWidth, 1, count);
+ int rows = (count + columns - 1) / columns;
+
+ int atlasWidth = columns * frameWidth;
+ int atlasHeight = rows * frameHeight;
+
+ if (atlasWidth > maxAtlasWidth || atlasHeight > maxAtlasWidth)
+ ThrowHelper.ThrowInvalidOperation($"This animation has {count} frames of {frameWidth}x{frameHeight}; packed as a grid it needs a {atlasWidth}x{atlasHeight} texture, which exceeds the {maxAtlasWidth} maximum atlas dimension. Raise maxAtlasWidth if the target hardware supports a larger texture, or decode this animation frame-by-frame with AnimationDecoder instead of atlasing it.");
+
+ using Surface atlas = new(atlasWidth, atlasHeight, animation.Frames[0].Format);
+
+ RectI[] regions = new RectI[count];
+ TimeSpan[] durations = new TimeSpan[count];
+
+ for (int i = 0; i < count; i++)
+ {
+ RectI region = new(i % columns * frameWidth, i / columns * frameHeight, frameWidth, frameHeight);
+
+ atlas.Blit(animation.Frames[i], destination: region);
+
+ regions[i] = region;
+ durations[i] = animation.Delays[i];
+ }
+
+ Texture texture = Texture.FromSurface(renderer, atlas);
+ SpriteSheet sheet = new(texture, regions);
+
+ int[] frames = new int[count];
+ for (int i = 0; i < count; i++)
+ frames[i] = i;
+
+ return (sheet, new SpriteAnimation(frames, durations));
+ }
+}
diff --git a/src/KappaDuck.Quack/Input/Cursor.cs b/src/KappaDuck.Quack/Input/Cursor.cs
index d7d4ac1..ba02140 100644
--- a/src/KappaDuck.Quack/Input/Cursor.cs
+++ b/src/KappaDuck.Quack/Input/Cursor.cs
@@ -88,8 +88,7 @@ public Cursor(ReadOnlySpan data, ReadOnlySpan mask, Size size, Point
/// exactly / 8 * bytes long.
///
/// Failed to create the cursor.
- public Cursor(ReadOnlySpan data, ReadOnlySpan mask, int width, int height, Point hotspot)
- : this(CreateMonochrome(data, mask, width, height, hotspot), true)
+ public Cursor(ReadOnlySpan data, ReadOnlySpan mask, int width, int height, Point hotspot) : this(CreateMonochrome(data, mask, width, height, hotspot), true)
{
}
@@ -115,6 +114,25 @@ public Cursor(ReadOnlySpan frames, Point hotspot) : this(CreateAnim
{
}
+ ///
+ /// Creates an animated cursor from an already loaded or built animation.
+ ///
+ ///
+ ///
+ /// The hotspot applies to every frame, and every frame in shares the same dimensions.
+ ///
+ ///
+ /// Useful when the frames come from a file like a GIF, APNG, or Windows .ani cursor loaded with .
+ /// For building one from scratch out of individual frames, is more direct.
+ ///
+ ///
+ /// The animation to cycle through.
+ /// The cursor hotspot, shared by every frame.
+ /// Failed to create the cursor.
+ public Cursor(Animation animation, Point hotspot) : this(SDL3_image.CreateAnimatedCursor(animation.Handle, hotspot.X, hotspot.Y), true)
+ {
+ }
+
private Cursor(SDL_Cursor* handle, bool owned)
{
QuackEngine.EnsureInitialized(Subsystem.Video);
@@ -213,7 +231,7 @@ public static Cursor FromStream(Stream stream, Point hotspot)
{
ArgumentOutOfRangeException.ThrowIfZero(frames.Length);
- SDL_CursorFrameInfo[] info = new SDL_CursorFrameInfo[frames.Length];
+ Span info = new SDL_CursorFrameInfo[frames.Length];
for (int i = 0; i < frames.Length; i++)
{
diff --git a/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_Animation.cs b/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_Animation.cs
new file mode 100644
index 0000000..e20b663
--- /dev/null
+++ b/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_Animation.cs
@@ -0,0 +1,27 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+namespace KappaDuck.Quack.Interop.SDL.Primitives;
+
+[StructLayout(LayoutKind.Sequential)]
+internal readonly struct IMG_Animation
+{
+ internal IMG_Animation(int width, int height, int count, SDL_Surface** frames, int* delays)
+ {
+ Width = width;
+ Height = height;
+ Count = count;
+ Frames = frames;
+ Delays = delays;
+ }
+
+ internal readonly int Width { get; }
+
+ internal readonly int Height { get; }
+
+ internal readonly int Count { get; }
+
+ internal readonly SDL_Surface** Frames { get; }
+
+ internal readonly int* Delays { get; }
+}
diff --git a/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_AnimationDecoder.cs b/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_AnimationDecoder.cs
new file mode 100644
index 0000000..1690a88
--- /dev/null
+++ b/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_AnimationDecoder.cs
@@ -0,0 +1,6 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+namespace KappaDuck.Quack.Interop.SDL.Primitives;
+
+internal readonly struct IMG_AnimationDecoder;
diff --git a/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_AnimationEncoder.cs b/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_AnimationEncoder.cs
new file mode 100644
index 0000000..185a120
--- /dev/null
+++ b/src/KappaDuck.Quack/Interop/SDL/Primitives/IMG_AnimationEncoder.cs
@@ -0,0 +1,6 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+namespace KappaDuck.Quack.Interop.SDL.Primitives;
+
+internal readonly struct IMG_AnimationEncoder;
diff --git a/src/KappaDuck.Quack/Interop/SDL/SDL3_image.cs b/src/KappaDuck.Quack/Interop/SDL/SDL3_image.cs
index 82c6a8d..c6e56c4 100644
--- a/src/KappaDuck.Quack/Interop/SDL/SDL3_image.cs
+++ b/src/KappaDuck.Quack/Interop/SDL/SDL3_image.cs
@@ -2,11 +2,47 @@
// Licensed under the MIT license.
using KappaDuck.Quack.Interop.SDL.Primitives;
+using KappaDuck.Quack.Video.Pixels;
namespace KappaDuck.Quack.Interop.SDL;
internal static partial class SDL3_image
{
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_AddAnimationEncoderFrame")]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ [return: MarshalAs(UnmanagedType.I1)]
+ internal static partial bool AddAnimationEncoderFrame(IMG_AnimationEncoder* encoder, SDL_Surface* frame, ulong duration);
+
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_CloseAnimationDecoder")]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ [return: MarshalAs(UnmanagedType.I1)]
+ internal static partial bool CloseAnimationDecoder(IMG_AnimationDecoder* decoder);
+
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_CloseAnimationEncoder")]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ [return: MarshalAs(UnmanagedType.I1)]
+ internal static partial bool CloseAnimationEncoder(IMG_AnimationEncoder* encoder);
+
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_CreateAnimatedCursor")]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ internal static partial SDL_Cursor* CreateAnimatedCursor(IMG_Animation* animation, int hotX, int hotY);
+
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_CreateAnimationDecoder", StringMarshalling = StringMarshalling.Utf8)]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ internal static partial IMG_AnimationDecoder* CreateAnimationDecoder(string file);
+
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_CreateAnimationDecoder_IO", StringMarshalling = StringMarshalling.Utf8)]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ internal static partial IMG_AnimationDecoder* CreateAnimationDecoder(SDL_IOStream* source, [MarshalAs(UnmanagedType.I1)] bool closeIO, string? type);
+
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_CreateAnimationEncoderWithProperties")]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ internal static partial IMG_AnimationEncoder* CreateAnimationEncoderWithProperties(uint properties);
+
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_FreeAnimation")]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ internal static partial void FreeAnimation(IMG_Animation* animation);
+
[LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_Load", StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial SDL_Surface* FromFile(string file);
@@ -23,6 +59,41 @@ internal static partial class SDL3_image
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial SDL_Texture* FromStream(SDL_Renderer* renderer, SDL_IOStream* stream, [MarshalAs(UnmanagedType.I1)] bool closeIO);
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_GetAnimationDecoderFrame")]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ [return: MarshalAs(UnmanagedType.I1)]
+ internal static partial bool GetAnimationDecoderFrame(IMG_AnimationDecoder* decoder, SDL_Surface** frame, ulong* duration);
+
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_GetAnimationDecoderStatus")]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ internal static partial AnimationDecoderStatus GetAnimationDecoderStatus(IMG_AnimationDecoder* decoder);
+
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_GetAnimationDecoderPresentationTimestampMS")]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ internal static partial int GetAnimationDecoderPresentationTimestampMS(IMG_AnimationDecoder* decoder, long pts);
+
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_GetAnimationDecoderProperties")]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ internal static partial uint GetAnimationDecoderProperties(IMG_AnimationDecoder* decoder);
+
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_GetNextAnimationDecoderFrame")]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ [return: MarshalAs(UnmanagedType.I1)]
+ internal static partial bool GetNextAnimationDecoderFrame(IMG_AnimationDecoder* decoder, SDL_Surface** frame, long* pts);
+
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_LoadAnimation", StringMarshalling = StringMarshalling.Utf8)]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ internal static partial IMG_Animation* LoadAnimation(string file);
+
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_LoadAnimation_IO")]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ internal static partial IMG_Animation* LoadAnimation(SDL_IOStream* stream, [MarshalAs(UnmanagedType.I1)] bool closeIO);
+
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_ResetAnimationDecoder")]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ [return: MarshalAs(UnmanagedType.I1)]
+ internal static partial bool ResetAnimationDecoder(IMG_AnimationDecoder* decoder);
+
[LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_Save", StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
[return: MarshalAs(UnmanagedType.I1)]
@@ -33,6 +104,16 @@ internal static partial class SDL3_image
[return: MarshalAs(UnmanagedType.I1)]
internal static partial bool Save(SDL_Surface* surface, SDL_IOStream* stream, string type);
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_SaveAnimation", StringMarshalling = StringMarshalling.Utf8)]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ [return: MarshalAs(UnmanagedType.I1)]
+ internal static partial bool SaveAnimation(IMG_Animation* animation, string file);
+
+ [LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_SaveAnimationTyped_IO", StringMarshalling = StringMarshalling.Utf8)]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ [return: MarshalAs(UnmanagedType.I1)]
+ internal static partial bool SaveAnimation(IMG_Animation* animation, SDL_IOStream* stream, [MarshalAs(UnmanagedType.I1)] bool closeIO, string type);
+
[LibraryImport(nameof(SDL3_image), EntryPoint = "IMG_SaveAVIF", StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
[return: MarshalAs(UnmanagedType.I1)]
diff --git a/src/KappaDuck.Quack/Video/AnimationFormat.cs b/src/KappaDuck.Quack/Video/AnimationFormat.cs
new file mode 100644
index 0000000..3580ac9
--- /dev/null
+++ b/src/KappaDuck.Quack/Video/AnimationFormat.cs
@@ -0,0 +1,51 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+namespace KappaDuck.Quack.Video;
+
+///
+/// Represents an animated image format.
+///
+public enum AnimationFormat
+{
+ ///
+ /// The ANI (Windows animated cursor) format.
+ ///
+ ANI = 0,
+
+ ///
+ /// The APNG (Animated PNG) format.
+ ///
+ APNG = 1,
+
+ ///
+ /// The AVIFS (AVIF image sequence) format.
+ ///
+ AVIFS = 2,
+
+ ///
+ /// The GIF format.
+ ///
+ GIF = 3,
+
+ ///
+ /// The WEBP format.
+ ///
+ WEBP = 4
+}
+
+internal static class AnimationFormatExtensions
+{
+ extension(AnimationFormat format)
+ {
+ public string Type => format switch
+ {
+ AnimationFormat.ANI => nameof(AnimationFormat.ANI),
+ AnimationFormat.APNG => nameof(AnimationFormat.APNG),
+ AnimationFormat.AVIFS => nameof(AnimationFormat.AVIFS),
+ AnimationFormat.GIF => nameof(AnimationFormat.GIF),
+ AnimationFormat.WEBP => nameof(AnimationFormat.WEBP),
+ _ => throw new NotImplementedException(),
+ };
+ }
+}
diff --git a/src/KappaDuck.Quack/Video/Pixels/Animation.cs b/src/KappaDuck.Quack/Video/Pixels/Animation.cs
new file mode 100644
index 0000000..d962261
--- /dev/null
+++ b/src/KappaDuck.Quack/Video/Pixels/Animation.cs
@@ -0,0 +1,157 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+using KappaDuck.Quack.Exceptions;
+using KappaDuck.Quack.Interop.SDL.Primitives;
+
+namespace KappaDuck.Quack.Video.Pixels;
+
+///
+/// A fully decoded animated image (such as a GIF, APNG, animated WebP, AVIF sequence, or Windows ANI cursor),
+/// exposing every frame and its display duration.
+///
+///
+/// Unlike , which decodes one frame at a time, an holds every
+/// frame in memory at once. Prefer it for short clips and cursors; prefer for long or
+/// unbounded animations. Frames are owned by the animation and become invalid once it is disposed; disposing an
+/// individual frame from does nothing.
+///
+public sealed class Animation : IDisposable
+{
+ private Animation(IMG_Animation* handle, Surface[] frames, TimeSpan[] delays, int loopCount)
+ {
+ Handle = handle;
+ Frames = frames;
+ Delays = delays;
+ LoopCount = loopCount;
+
+ unsafe
+ {
+ Width = Handle->Width;
+ Height = Handle->Height;
+ }
+ }
+
+ ///
+ /// Gets the width of every frame, in pixels.
+ ///
+ public int Width { get; }
+
+ ///
+ /// Gets the height of every frame, in pixels.
+ ///
+ public int Height { get; }
+
+ ///
+ /// Gets the number of frames.
+ ///
+ public int FrameCount { get; }
+
+ ///
+ /// Gets every frame, in playback order.
+ ///
+ /// Frames are owned by the animation; disposing one individually does nothing, and they become invalid once the animation is disposed.
+ public IReadOnlyList Frames { get; }
+
+ ///
+ /// Gets how long each corresponding frame in is shown.
+ ///
+ public IReadOnlyList Delays { get; }
+
+ ///
+ /// Gets the number of times the animation is intended to play, or 0 to loop forever.
+ ///
+ public int LoopCount { get; }
+
+ internal IMG_Animation* Handle { get; private set; }
+
+ ///
+ public void Dispose()
+ {
+ if (Handle is null)
+ return;
+
+ SDL3_image.FreeAnimation(Handle);
+ Handle = null;
+ }
+
+ ///
+ /// Loads every frame of an animated image file into memory.
+ ///
+ /// The path to the animated image file.
+ /// The loaded animation.
+ /// The file does not exist.
+ /// The animation could not be loaded.
+ public static Animation Load(string path)
+ {
+ if (!File.Exists(path))
+ ThrowHelper.ThrowFileNotFound("The file path does not exist.", path);
+
+ IMG_Animation* handle = SDL3_image.LoadAnimation(path);
+ SDLThrowHelper.ThrowIfNull(handle);
+
+ return Create(handle);
+ }
+
+ ///
+ /// Loads every frame of an animated image from a stream into memory.
+ ///
+ /// The stream to read the animated image from.
+ /// The loaded animation.
+ /// The animation could not be loaded.
+ public static Animation Load(Stream stream)
+ {
+ using IOStream source = IOStream.FromStream(stream);
+
+ IMG_Animation* handle = SDL3_image.LoadAnimation(source.Handle, false);
+ SDLThrowHelper.ThrowIfNull(handle);
+
+ return Create(handle);
+ }
+
+ ///
+ /// Saves the animation to a file. The format is chosen from the file extension.
+ ///
+ /// The path to write the animated image to.
+ /// The animation could not be saved.
+ /// The animation is disposed.
+ public void Save(string path)
+ {
+ ThrowIfDisposed();
+ SDLThrowHelper.ThrowIfFailed(SDL3_image.SaveAnimation(Handle, path));
+ }
+
+ ///
+ /// Saves the animation to a stream, in the given format.
+ ///
+ /// The stream to write the animated image to.
+ /// The animation format to save as.
+ /// The animation could not be saved.
+ /// The animation is disposed.
+ public void Save(Stream stream, AnimationFormat format)
+ {
+ ThrowIfDisposed();
+
+ using IOStream destination = IOStream.FromStream(stream);
+ SDLThrowHelper.ThrowIfFailed(SDL3_image.SaveAnimation(Handle, destination.Handle, false, format.Type));
+ }
+
+ private static unsafe Animation Create(IMG_Animation* handle)
+ {
+ int count = handle->Count;
+
+ Surface[] frames = new Surface[count];
+ TimeSpan[] delays = new TimeSpan[count];
+
+ for (int i = 0; i < count; i++)
+ {
+ frames[i] = new Surface(handle->Frames[i], false);
+ delays[i] = TimeSpan.FromMilliseconds(handle->Delays[i]);
+ }
+
+ int loopCount = (int)SDL3.GetNumberProperty(SDL3.GetSurfaceProperties(handle->Frames[0]), "SDL_image.metadata.loop_count", 0);
+ return new Animation(handle, frames, delays, loopCount);
+ }
+
+ private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(Handle is null, typeof(Animation));
+}
diff --git a/src/KappaDuck.Quack/Video/Pixels/AnimationDecoder.cs b/src/KappaDuck.Quack/Video/Pixels/AnimationDecoder.cs
new file mode 100644
index 0000000..842914c
--- /dev/null
+++ b/src/KappaDuck.Quack/Video/Pixels/AnimationDecoder.cs
@@ -0,0 +1,217 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+using KappaDuck.Quack.Exceptions;
+using KappaDuck.Quack.Interop.SDL.Primitives;
+
+namespace KappaDuck.Quack.Video.Pixels;
+
+///
+/// Decodes an animated image (such as a GIF, WebP, APNG, or AVIF) one frame at a time.
+///
+///
+/// Frames are produced on demand, so only one is decoded into memory at a time — unlike loading a whole animation up
+/// front with . Each frame is an independent that the caller owns and
+/// must dispose. Read frames with (which reports how long each frame is shown) or
+/// (which reports the timestamp to show each frame at), and use
+/// to loop back to the first frame.
+///
+public sealed class AnimationDecoder : IDisposable
+{
+ private IMG_AnimationDecoder* _decoder;
+ private IOStream? _stream;
+
+ private AnimationDecoder(IMG_AnimationDecoder* decoder, IOStream? stream)
+ {
+ _decoder = decoder;
+ _stream = stream;
+
+ uint properties = SDL3_image.GetAnimationDecoderProperties(decoder);
+
+ FrameCount = (int)SDL3.GetNumberProperty(properties, "SDL_image.metadata.frame_count", 0);
+ LoopCount = (int)SDL3.GetNumberProperty(properties, "SDL_image.metadata.loop_count", 0);
+
+ Title = SDL3.GetStringProperty(properties, "SDL_image.metadata.title", string.Empty);
+ Author = SDL3.GetStringProperty(properties, "SDL_image.metadata.author", string.Empty);
+ Description = SDL3.GetStringProperty(properties, "SDL_image.metadata.description", string.Empty);
+ Copyright = SDL3.GetStringProperty(properties, "SDL_image.metadata.copyright", string.Empty);
+ CreationTime = SDL3.GetStringProperty(properties, "SDL_image.metadata.creation_time", string.Empty);
+ }
+
+ ///
+ /// Opens an animated image file for decoding. The format is detected from the file contents.
+ ///
+ /// The path to the animated image file.
+ /// A decoder positioned before the first frame.
+ /// The file could not be opened for decoding.
+ public static AnimationDecoder Open(string path)
+ {
+ IMG_AnimationDecoder* decoder = SDL3_image.CreateAnimationDecoder(path);
+ SDLThrowHelper.ThrowIfNull(decoder);
+
+ return new AnimationDecoder(decoder, null);
+ }
+
+ ///
+ /// Opens an animated image from a stream for decoding.
+ ///
+ /// The stream is read on demand as frames are decoded, so it must stay readable until the decoder is disposed.
+ /// The stream to read the animated image from.
+ /// The animation format to decode as.
+ /// A decoder positioned before the first frame.
+ /// The stream could not be opened for decoding.
+ public static AnimationDecoder Open(Stream stream, AnimationFormat format)
+ {
+ IOStream source = IOStream.FromStream(stream);
+
+ IMG_AnimationDecoder* decoder;
+ try
+ {
+ decoder = SDL3_image.CreateAnimationDecoder(source.Handle, false, format.Type);
+ SDLThrowHelper.ThrowIfNull(decoder);
+ }
+ catch
+ {
+ source.Dispose();
+ throw;
+ }
+
+ return new AnimationDecoder(decoder, source);
+ }
+
+ ///
+ /// Gets the current state of the decoder.
+ ///
+ public AnimationDecoderStatus Status => SDL3_image.GetAnimationDecoderStatus(_decoder);
+
+ ///
+ /// Gets the number of frames in the animation, or 0 if the format does not report it.
+ ///
+ public int FrameCount { get; }
+
+ ///
+ /// Gets the number of times the animation is intended to play, or 0 to loop forever.
+ ///
+ public int LoopCount { get; }
+
+ ///
+ /// Gets the animation's title, or an empty string if the format does not report one.
+ ///
+ public string Title { get; }
+
+ ///
+ /// Gets the animation's author, or an empty string if the format does not report one.
+ ///
+ public string Author { get; }
+
+ ///
+ /// Gets the animation's description, or an empty string if the format does not report one.
+ ///
+ public string Description { get; }
+
+ ///
+ /// Gets the animation's copyright notice, or an empty string if the format does not report one.
+ ///
+ public string Copyright { get; }
+
+ ///
+ /// Gets the animation's creation time, or an empty string if the format does not report one.
+ ///
+ public string CreationTime { get; }
+
+ ///
+ public void Dispose()
+ {
+ if (_decoder is null)
+ return;
+
+ SDL3_image.CloseAnimationDecoder(_decoder);
+ _decoder = null;
+
+ _stream?.Dispose();
+ _stream = null;
+ }
+
+ ///
+ /// Rewinds the decoder to the first frame.
+ ///
+ /// The decoder could not be reset.
+ public void Reset() => SDLThrowHelper.ThrowIfFailed(SDL3_image.ResetAnimationDecoder(_decoder));
+
+ ///
+ /// Reads the next frame of the animation.
+ ///
+ ///
+ /// When this method returns , the decoded frame; the caller owns it and must dispose it.
+ /// Otherwise .
+ ///
+ ///
+ /// When this method returns , how long the frame is shown before the next one. Otherwise
+ /// .
+ ///
+ ///
+ /// if a frame was decoded; once the animation is complete.
+ ///
+ /// Decoding failed.
+ public bool TryReadFrame([NotNullWhen(true)] out Surface? frame, out TimeSpan duration)
+ {
+ SDL_Surface* surface;
+ ulong milliseconds;
+
+ if (SDL3_image.GetAnimationDecoderFrame(_decoder, &surface, &milliseconds))
+ {
+ frame = new Surface(surface, true);
+ duration = TimeSpan.FromMilliseconds(milliseconds);
+
+ return true;
+ }
+
+ frame = null;
+ duration = TimeSpan.Zero;
+
+ SDLThrowHelper.ThrowIf(Status == AnimationDecoderStatus.Failed);
+ return false;
+ }
+
+ ///
+ /// Reads the next frame of the animation along with the time it should be shown at.
+ ///
+ ///
+ /// Unlike , which reports how long each frame is shown, this reports the timestamp at
+ /// which the frame should be presented, measured from the start of the animation — convenient for synchronising
+ /// playback to a clock.
+ ///
+ ///
+ /// When this method returns , the decoded frame; the caller owns it and must dispose it.
+ /// Otherwise .
+ ///
+ ///
+ /// When this method returns , the time from the start of the animation at which the frame
+ /// should be shown. Otherwise .
+ ///
+ ///
+ /// if a frame was decoded; once the animation is complete.
+ ///
+ /// Decoding failed.
+ public bool TryReadNextFrame([NotNullWhen(true)] out Surface? frame, out TimeSpan presentationTimestamp)
+ {
+ SDL_Surface* surface;
+ long pts;
+
+ if (SDL3_image.GetNextAnimationDecoderFrame(_decoder, &surface, &pts))
+ {
+ frame = new Surface(surface, owned: true);
+
+ int milliseconds = SDL3_image.GetAnimationDecoderPresentationTimestampMS(_decoder, pts);
+ presentationTimestamp = TimeSpan.FromMilliseconds(milliseconds);
+
+ return true;
+ }
+
+ frame = null;
+ presentationTimestamp = TimeSpan.Zero;
+
+ SDLThrowHelper.ThrowIf(Status == AnimationDecoderStatus.Failed);
+ return false;
+ }
+}
diff --git a/src/KappaDuck.Quack/Video/Pixels/AnimationDecoderStatus.cs b/src/KappaDuck.Quack/Video/Pixels/AnimationDecoderStatus.cs
new file mode 100644
index 0000000..a5eb9ea
--- /dev/null
+++ b/src/KappaDuck.Quack/Video/Pixels/AnimationDecoderStatus.cs
@@ -0,0 +1,30 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+namespace KappaDuck.Quack.Video.Pixels;
+
+///
+/// Describes the current state of an .
+///
+public enum AnimationDecoderStatus
+{
+ ///
+ /// The decoder is invalid, for example because it has already been closed.
+ ///
+ Invalid = -1,
+
+ ///
+ /// The decoder is active and more frames may still be available.
+ ///
+ Decoding = 0,
+
+ ///
+ /// Decoding failed. The most recent read did not produce a frame because of an error.
+ ///
+ Failed = 1,
+
+ ///
+ /// The end of the animation has been reached and every frame has been read.
+ ///
+ Complete = 2
+}
diff --git a/src/KappaDuck.Quack/Video/Pixels/AnimationEncoder.cs b/src/KappaDuck.Quack/Video/Pixels/AnimationEncoder.cs
new file mode 100644
index 0000000..88e056d
--- /dev/null
+++ b/src/KappaDuck.Quack/Video/Pixels/AnimationEncoder.cs
@@ -0,0 +1,133 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+using KappaDuck.Quack.Exceptions;
+using KappaDuck.Quack.Interop.SDL.Primitives;
+
+namespace KappaDuck.Quack.Video.Pixels;
+
+///
+/// Encodes frames into an animated image (such as a GIF, APNG, WebP, or AVIF sequence) one frame at a time.
+///
+///
+/// Frames are written as they are added, so memory use stays proportional to a single frame rather than the whole
+/// animation — the inverse of , which holds every frame in memory at once. Add every frame
+/// with , then dispose the encoder to finish writing.
+///
+public sealed class AnimationEncoder : IDisposable
+{
+ private IMG_AnimationEncoder* _encoder;
+ private IOStream? _stream;
+
+ private AnimationEncoder(IMG_AnimationEncoder* encoder, IOStream? stream)
+ {
+ _encoder = encoder;
+ _stream = stream;
+ }
+
+ ///
+ /// Adds the next frame to the animation.
+ ///
+ /// The frame to encode.
+ /// How long the frame is shown before the next one.
+ /// The frame could not be encoded.
+ /// The encoder is disposed.
+ public void AddFrame(Surface frame, TimeSpan duration)
+ {
+ ObjectDisposedException.ThrowIf(_encoder is null, typeof(AnimationEncoder));
+ SDLThrowHelper.ThrowIfFailed(SDL3_image.AddAnimationEncoderFrame(_encoder, frame.Handle, (ulong)duration.TotalMilliseconds));
+ }
+
+ ///
+ /// Creates an encoder that writes to a file. The format is chosen from the file extension.
+ ///
+ /// The path to write the animated image to.
+ /// Additional encoding options, or for the format's defaults.
+ /// An encoder ready to accept frames.
+ /// The file could not be opened for encoding.
+ public static AnimationEncoder Create(string path, AnimationEncoderOptions? options = null)
+ {
+ using Properties properties = BuildProperties(options);
+ properties.Set("SDL_image.animation_encoder.create.filename", path);
+
+ IMG_AnimationEncoder* encoder = SDL3_image.CreateAnimationEncoderWithProperties(properties);
+ SDLThrowHelper.ThrowIfNull(encoder);
+
+ return new AnimationEncoder(encoder, null);
+ }
+
+ ///
+ /// Creates an encoder that writes to a stream.
+ ///
+ /// The stream to write the animated image to.
+ /// The animation format to encode as.
+ /// Additional encoding options, or for the format's defaults.
+ /// An encoder ready to accept frames.
+ /// The stream could not be opened for encoding.
+ public static AnimationEncoder Create(Stream stream, AnimationFormat format, AnimationEncoderOptions? options = null)
+ {
+ IOStream destination = IOStream.FromStream(stream);
+
+ using Properties properties = BuildProperties(options);
+ properties.Set("SDL_image.animation_encoder.create.iostream", destination.Handle);
+ properties.Set("SDL_image.animation_encoder.create.type", format.Type);
+
+ IMG_AnimationEncoder* encoder;
+ try
+ {
+ encoder = SDL3_image.CreateAnimationEncoderWithProperties(properties);
+ SDLThrowHelper.ThrowIfNull(encoder);
+ }
+ catch
+ {
+ destination.Dispose();
+ throw;
+ }
+
+ return new(encoder, destination);
+ }
+
+ ///
+ /// Finishes encoding and releases the encoder's resources.
+ ///
+ /// The animation could not be finalized.
+ public void Dispose()
+ {
+ if (_encoder is null)
+ _encoder = null;
+
+ SDLThrowHelper.ThrowIfFailed(SDL3_image.CloseAnimationEncoder(_encoder));
+ _encoder = null;
+
+ _stream?.Dispose();
+ _stream = null;
+ }
+
+ private static Properties BuildProperties(AnimationEncoderOptions? options)
+ {
+ Properties properties = new();
+
+ if (options is null)
+ return properties;
+
+ if (options.Quality.HasValue)
+ properties.Set("SDL_image.animation_encoder.create.quality", options.Quality.Value);
+
+ if (options.TimebaseNumerator.HasValue)
+ properties.Set("SDL_image.animation_encoder.create.timebase.numerator", options.TimebaseNumerator.Value);
+
+ if (options.TimebaseDenominator.HasValue)
+ properties.Set("SDL_image.animation_encoder.create.timebase.denominator", options.TimebaseDenominator.Value);
+
+ if (options.GifUseLookupTable.HasValue)
+ properties.Set("SDL_image.animation_encoder.create.gif.use_lut", options.GifUseLookupTable.Value);
+
+ if (options.AvifKeyframeInterval.HasValue)
+ properties.Set("SDL_image.animation_encoder.create.avif.keyframe_interval", options.AvifKeyframeInterval.HasValue);
+
+ if (options.AvifMaxThreads.HasValue)
+ properties.Set("SDL_image.animation_encoder.create.avif.max_threads", options.AvifMaxThreads.Value);
+
+ return properties;
+ }
+}
diff --git a/src/KappaDuck.Quack/Video/Pixels/AnimationEncoderOptions.cs b/src/KappaDuck.Quack/Video/Pixels/AnimationEncoderOptions.cs
new file mode 100644
index 0000000..121ef0a
--- /dev/null
+++ b/src/KappaDuck.Quack/Video/Pixels/AnimationEncoderOptions.cs
@@ -0,0 +1,47 @@
+// Copyright (c) KappaDuck.
+// Licensed under the MIT license.
+
+namespace KappaDuck.Quack.Video.Pixels;
+
+///
+/// Optional settings for .
+///
+///
+/// Every setting is optional; unset ones fall back to the format's own default. Settings that don't apply to the
+/// chosen format (for example when encoding a GIF) are ignored.
+///
+public sealed class AnimationEncoderOptions
+{
+ ///
+ /// Gets or sets the encoding quality, from 0 to 100, for formats that support it (AVIF, WebP). Higher is better
+ /// quality and larger output.
+ ///
+ public int? Quality { get; set; }
+
+ ///
+ /// Gets or sets the numerator of the fraction used to convert a frame's duration to seconds. Defaults to 1.
+ ///
+ public int? TimebaseNumerator { get; set; }
+
+ ///
+ /// Gets or sets the denominator of the fraction used to convert a frame's duration to seconds. Defaults to 1000,
+ /// meaning durations passed to are interpreted as milliseconds.
+ ///
+ public int? TimebaseDenominator { get; set; }
+
+ ///
+ /// Gets or sets whether GIF encoding reuses a single shared color table across every frame instead of computing
+ /// a fresh palette per frame.
+ ///
+ public bool? GifUseLookupTable { get; set; }
+
+ ///
+ /// Gets or sets how often AVIF encoding inserts a keyframe.
+ ///
+ public int? AvifKeyframeInterval { get; set; }
+
+ ///
+ /// Gets or sets the maximum number of threads used for AVIF encoding.
+ ///
+ public int? AvifMaxThreads { get; set; }
+}