Android can read an animated WebP, but it can't save one. Bitmap.compress writes still WebP images only, as WEBP_LOSSY or WEBP_LOSSLESS (see Android's docs), and there's no built-in way to turn several of them into an animation.

That matters if you need animated WebP, for example for WhatsApp stickers, which must be animated WebP files. The usual advice online is to use FFmpegKit, but FFmpegKit has been officially retired, and its repository was archived in July 2026.

When I built sticker support for GifDrop, I needed another way. It turns out the format is simple enough to write by hand, and you don't need to encode any images yourself. This article walks through how an animated WebP is built, one byte at a time, and ends with a short Kotlin example that writes one.

A WebP file is a list of chunks

A WebP file uses RIFF, a simple container format. It starts with a 12-byte header, followed by a list of chunks. Every chunk has the same shape:

PartSizeMeaning
Name4 bytesFour letters, such as VP8X or ANMF
Size4 bytesHow many bytes of data follow, as a little-endian number
DataThat many bytesWhat the chunk holds
Padding0 or 1 byteAdded when the data's size is odd, so every chunk has an even length

A still image needs just one chunk with the picture in it: VP8 for lossy images or VP8L for lossless ones. An animated image adds a few more:

The chunks inside an animated WebP: a RIFF container holding VP8X with the flags and canvas size, ANIM with the background colour and loop count, and one ANMF chunk per frame, each holding a still image's VP8 or VP8L data

  • VP8X says the file is animated, whether it has transparency, and the size of the canvas.
  • ANIM holds the background colour and how many times the animation loops.
  • ANMF holds one frame: where it goes, its size, how long it's shown, and the frame's picture.

The trick: each frame is a still image

Look inside an ANMF chunk and you'll find something useful. After a 16-byte header, it holds exactly the same image chunks a still WebP has.

So you don't need an animated WebP encoder. You can:

  1. Save each frame as an ordinary still WebP, which Android already does.
  2. Copy the image chunks out of each one.
  3. Wrap them in ANMF chunks, and add VP8X and ANIM at the start.

That's just moving bytes around. No image encoding code is needed.

Reading a real file, byte by byte

Here's a complete animated WebP. It's tiny on purpose: three frames, 4 × 4 pixels each, in cyan, blue and violet, shown for half a second each and looping forever. It was made by the Kotlin code later in this article, and the whole file is 194 bytes.

The sample animated WebP: a square that changes from cyan to blue to violet every half second
The real 194-byte file, shown 40 times larger. Each frame is 4 × 4 pixels of one colour.

These are the first 94 bytes, which cover the headers and the first frame. Every number is little-endian, so the lowest byte comes first.

Offset  Bytes                                    Meaning
0x00    52 49 46 46                              "RIFF"
0x04    ba 00 00 00                              Size of everything after this field: 186 bytes
0x08    57 45 42 50                              "WEBP"
 
0x0c    56 50 38 58                              "VP8X"
0x10    0a 00 00 00                              10 bytes of data follow
0x14    02                                       Flags: animated, no transparency
0x15    00 00 00                                 Reserved
0x18    03 00 00                                 Canvas width - 1  = 3, so 4 pixels
0x1b    03 00 00                                 Canvas height - 1 = 3, so 4 pixels
 
0x1e    41 4e 49 4d                              "ANIM"
0x22    06 00 00 00                              6 bytes of data follow
0x26    00 00 00 00                              Background colour: transparent
0x2a    00 00                                    Loop count: 0, which means forever
 
0x2c    41 4e 4d 46                              "ANMF", the first frame
0x30    2a 00 00 00                              42 bytes of data follow
0x34    00 00 00                                 X position ÷ 2 = 0
0x37    00 00 00                                 Y position ÷ 2 = 0
0x3a    03 00 00                                 Frame width - 1  = 3
0x3d    03 00 00                                 Frame height - 1 = 3
0x40    f4 01 00                                 Duration: 0x01f4 = 500 ms
0x43    02                                       Flags: don't blend with the previous frame
 
0x44    56 50 38 4c                              "VP8L", the frame's lossless image
0x48    11 00 00 00                              17 bytes of image data follow
0x4c    2f 03 c0 00 00 07 d0 f7 16 f4 bc ff ...  The image itself, copied from a still WebP
0x5d    00                                       Padding, because 17 is odd

The second and third frames follow straight after, at offsets 0x5e and 0x90. They're identical to the first apart from their image data, because only the colour changes.

The numbers add up exactly. The first ANMF chunk holds 42 bytes: its 16-byte header plus the 26-byte VP8L chunk, which is an 8-byte header, 17 bytes of image and 1 byte of padding.

The details that are easy to get wrong

The format is simple, but a few rules catch people out. The specification is careful about each of them:

  • Sizes are stored minus one. A 512-pixel canvas is written as 511.
  • Frame positions are stored halved. The value you write is the real position divided by 2.
  • Chunks must have an even length. An odd-sized chunk gets one zero byte of padding, which isn't counted in its size.
  • The RIFF size skips the first 8 bytes. It counts everything after the size field itself.
  • Many numbers are 24 bits. Widths, heights, positions and durations take three bytes, so Java's ByteBuffer has no ready-made method for them.
  • A 1 in the blending bit means "don't blend". It's easy to read it the other way round.
  • The transparency flag must match the frames. Set it when any frame has transparency, and leave it off when none do.

Writing one in Kotlin

This example builds an animated WebP from still WebP frames. It's written for this article to show the format clearly, rather than taken from an app. It runs on the JVM with only the standard library, so it works the same on Android.

import java.io.ByteArrayOutputStream
 
/** One animation frame: a still WebP file, and how long to show it. */
class Frame(val webp: ByteArray, val durationMs: Int)
 
/** Image data copied out of a still WebP, ready to go inside an ANMF chunk. */
class ImageChunks(val bytes: ByteArray, val hasAlpha: Boolean)
 
/** Reads a 32-bit little-endian number. */
fun ByteArray.u32(at: Int): Long =
    (this[at].toLong() and 0xFF) or
        ((this[at + 1].toLong() and 0xFF) shl 8) or
        ((this[at + 2].toLong() and 0xFF) shl 16) or
        ((this[at + 3].toLong() and 0xFF) shl 24)
 
/** Writes little-endian numbers of 16, 24 and 32 bits. */
fun ByteArrayOutputStream.u16(v: Int) { write(v and 0xFF); write(v shr 8 and 0xFF) }
fun ByteArrayOutputStream.u24(v: Int) { u16(v); write(v shr 16 and 0xFF) }
fun ByteArrayOutputStream.u32(v: Int) { u16(v); u16(v ushr 16) }
 
/** Writes one chunk: a four-letter name, the payload's size, the payload, and a padding byte if odd. */
fun ByteArrayOutputStream.chunk(name: String, payload: ByteArray) {
    write(name.toByteArray(Charsets.US_ASCII))
    u32(payload.size)
    write(payload)
    if (payload.size % 2 == 1) write(0)
}
 
/** Copies the ALPH, VP8 and VP8L chunks out of a still WebP, padding included. */
fun imageChunksOf(still: ByteArray): ImageChunks {
    require(String(still, 0, 4, Charsets.US_ASCII) == "RIFF" && String(still, 8, 4, Charsets.US_ASCII) == "WEBP") {
        "Not a WebP file"
    }
    val out = ByteArrayOutputStream()
    var hasAlpha = false
    var pos = 12
    while (pos + 8 <= still.size) {
        val name = String(still, pos, 4, Charsets.US_ASCII)
        val size = still.u32(pos + 4).toInt()
        val padded = size + (size and 1)
        when (name) {
            "ALPH" -> hasAlpha = true
            // A lossless image says whether it uses alpha in bit 28 of its header.
            "VP8L" -> if (still[pos + 8 + 4].toInt() and 0x10 != 0) hasAlpha = true
        }
        if (name == "ALPH" || name == "VP8 " || name == "VP8L") {
            out.write(still, pos, 8 + padded)
        }
        pos += 8 + padded
    }
    return ImageChunks(out.toByteArray(), hasAlpha)
}
 
/** Builds an animated WebP from still frames. A loop count of 0 means "loop forever". */
fun animatedWebp(width: Int, height: Int, frames: List<Frame>, loopCount: Int = 0): ByteArray {
    val images = frames.map { imageChunksOf(it.webp) }
    val body = ByteArrayOutputStream()
 
    // VP8X: says the file is animated, whether it has alpha, and the canvas size.
    val vp8x = ByteArrayOutputStream()
    val alphaFlag = if (images.any { it.hasAlpha }) 0x10 else 0
    vp8x.write(0x02 or alphaFlag) // 0x02 = animation
    vp8x.u24(0) // Reserved
    vp8x.u24(width - 1) // Sizes are stored minus one
    vp8x.u24(height - 1)
    body.chunk("VP8X", vp8x.toByteArray())
 
    // ANIM: background colour and how many times to loop.
    val anim = ByteArrayOutputStream()
    anim.u32(0) // Background colour: transparent
    anim.u16(loopCount)
    body.chunk("ANIM", anim.toByteArray())
 
    // ANMF: one per frame. Each covers the whole canvas and replaces the frame before it.
    frames.zip(images).forEach { (frame, image) ->
        val anmf = ByteArrayOutputStream()
        anmf.u24(0) // X offset ÷ 2
        anmf.u24(0) // Y offset ÷ 2
        anmf.u24(width - 1)
        anmf.u24(height - 1)
        anmf.u24(frame.durationMs)
        anmf.write(0x02) // Don't blend with the frame before, and don't dispose of it after
        anmf.write(image.bytes)
        body.chunk("ANMF", anmf.toByteArray())
    }
 
    // RIFF header: its size counts everything after the size field itself.
    val file = ByteArrayOutputStream()
    file.write("RIFF".toByteArray(Charsets.US_ASCII))
    file.u32(4 + body.size())
    file.write("WEBP".toByteArray(Charsets.US_ASCII))
    body.writeTo(file)
    return file.toByteArray()
}

On Android, each frame's still WebP comes from Bitmap.compress. Every frame here should be the same size as the canvas:

val out = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSY, 80, out) // WEBP_LOSSLESS also works
val frame = Frame(out.toByteArray(), durationMs = 100)

WEBP_LOSSY and WEBP_LOSSLESS were added in Android 11. On older versions, use the deprecated WEBP format instead. It makes lossy files, except at quality 100 on Android 10 and later, where it makes lossless ones.

Checking your file

A file you've written by hand deserves a check. The quickest one is to open it in a browser, which will play it if it's valid. In code, the same idea works: walk the chunks again and confirm that:

  • the RIFF size equals the file's length minus 8
  • VP8X has the animation flag set
  • there's one ANMF chunk per frame, each with the duration you expect
  • every chunk's size and padding lead exactly to the next chunk, with nothing left over at the end

Android's own decoder can play the result too. ImageDecoder and AnimatedImageDrawable show animated WebP from Android 9.

Key takeaways

  • Android can save still WebP images, but not animated ones, and FFmpegKit, the usual workaround, has been retired.
  • A WebP file is a RIFF container: a 12-byte header and a list of chunks, each with a name, a size, its data and padding to an even length.
  • An animated WebP adds three chunk types: VP8X for the flags and canvas size, ANIM for the loop count, and one ANMF per frame.
  • Each ANMF chunk holds the same image data as a still WebP, so you can build an animation by copying bytes, with no encoder.
  • Watch the small rules: sizes minus one, positions halved, 24-bit numbers, even-length chunks, and a RIFF size that skips the first 8 bytes.

Next: Fitting a video into a WhatsApp sticker under 500 KB, on keeping each animated sticker under WhatsApp's limit.