Inside a WebP file: RIFF chunks, VP8, VP8L and animation
A .webp is three formats wearing one extension: a lossy image that is really a single frame of VP8 video, a lossless image with its own entropy coder, and an animation that can mix the two frame by frame. All three live in the same simple container, a RIFF file of tagged chunks. This is a tour of that container using the bytes of a real file, plus the JavaScript to read it: how to tell lossy from lossless, how to recover the quality a lossy WebP was saved at, how an animation is put back together frame by frame, and why a WebP can never be larger than 4 GiB.
A WebP is a RIFF file
RIFF is the 1991 container behind WAV and AVI. A RIFF file opens with the four letters RIFF, a 32-bit little-endian size (the number of bytes that follow it), and a four-letter form type, which for WebP is WEBP. After that come chunks: a four-character id, a 32-bit little-endian payload size, the payload, and one zero byte of padding if the size is odd. These are the first 32 bytes of the sample the WebP viewer ships, a 34-frame animation of a bamboo grove:
00000000: 52 49 46 46 20 9c 04 00 57 45 42 50 56 50 38 58 RIFF ....WEBPVP8X
00000010: 0a 00 00 00 2e 00 00 00 3f 01 00 b3 00 00 49 43 ........?.....IC
└ "RIFF" └ 0x00049c20 └ "WEBP" └ first chunk: VP8X, 10 bytes
= 302,112 bytes follow, so the file is 302,120
That size field is the first thing to check. If it is larger than the file, the file was cut short. If it is smaller, something has been appended after the image, and decoders will ignore it. Both happen in the wild, and both are worth saying out loud rather than quietly coping with.
Simple and extended
There are three ways to lay out a WebP. A simple lossy file has exactly one chunk, VP8 (note the trailing space; ids are always four bytes). A simple lossless file has one VP8L chunk. Anything else (alpha on a lossy image, an animation, a colour profile, metadata) needs the extended layout, which starts with a VP8X header chunk and then lists the rest in a fixed order:
| Chunk | What it holds |
|---|---|
VP8X | canvas width and height, and five feature flags |
ICCP | an ICC colour profile |
ANIM | animation header: background colour and loop count |
ANMF × n | one per frame: position, size, duration, blend and dispose, then the frame's own image chunks |
ALPH | the alpha plane of a lossy still (lossless images carry alpha inside VP8L) |
VP8 / VP8L | the image data of a still |
EXIF, XMP | metadata, at the end |
Readers are told to skip chunks they do not know. That is good for compatibility and bad for privacy: an unknown chunk is invisible to every viewer and survives every re-save that copies chunks through.
Walking the chunks
Because every chunk states its own size, you can map a whole WebP by reading eight bytes per chunk and jumping over each payload. The image data is never read:
const fourcc = (u8, o) => String.fromCharCode(u8[o], u8[o + 1], u8[o + 2], u8[o + 3]);
const u32le = (u8, o) => (u8[o] | u8[o + 1] << 8 | u8[o + 2] << 16 | u8[o + 3] << 24) >>> 0;
async function* walkChunks(file) {
const head = new Uint8Array(await file.slice(0, 12).arrayBuffer());
if (fourcc(head, 0) !== 'RIFF' || fourcc(head, 8) !== 'WEBP') throw new Error('not a WebP');
const end = Math.min(file.size, u32le(head, 4) + 8); // where the RIFF says it stops
for (let off = 12; off + 8 <= end;) {
const h = new Uint8Array(await file.slice(off, off + 8).arrayBuffer());
const size = u32le(h, 4);
yield { id: fourcc(h, 0), offset: off, size };
off += 8 + size + (size & 1); // header + payload + pad byte
}
}
The real walker in OmniViewer adds two things. It reads through a window, so a run of small chunks costs one read instead of many, and it reads a few dozen bytes into each image chunk, because that is where the facts worth showing live.
The VP8X flags
The VP8X payload is ten bytes: one byte of flags, three reserved bytes, then the canvas width minus one and height minus one as 24-bit little-endian numbers. In the sample above that is 2e 00 00 00 3f 01 00 b3 00 00:
const flags = d[0]; // 0x2e = 0b0010_1110
const vp8x = {
icc: !!(flags & 0x20), // true: there is an ICCP chunk
alpha: !!(flags & 0x10), // false
exif: !!(flags & 0x08), // true
xmp: !!(flags & 0x04), // true
animation: !!(flags & 0x02), // true
width: (d[4] | d[5] << 8 | d[6] << 16) + 1, // 0x00013f + 1 = 320
height: (d[7] | d[8] << 8 | d[9] << 16) + 1, // 0x0000b3 + 1 = 180
};
The flags are promises about which chunks follow. A good reader checks them against what it actually finds: an EXIF flag with no EXIF chunk, or an EXIF chunk with no flag, is a file some tool edited carelessly. Strict decoders skip an unflagged chunk, so the second case can hide metadata from one program and show it to another.
Lossy: one VP8 key frame
A lossy WebP is a single key frame of VP8 video, the codec of WebM's first generation, specified in RFC 6386. The first ten bytes are plain: a 3-byte frame tag, the start code 9d 01 2a, and the dimensions. In the sample's first frame they read d0 92 00 9d 01 2a 40 01 b4 00:
const tag = d[0] | d[1] << 8 | d[2] << 16; // 0x0092d0
const keyFrame = !(tag & 1); // true: a WebP holds exactly one key frame
const showFrame = (tag >> 4) & 1; // 1
const firstPartSize = tag >> 5; // 1,174 bytes of frame header + modes
// d[3..5] must be 9d 01 2a
const width = (d[6] | d[7] << 8) & 0x3fff; // 320 (top 2 bits: an upscale hint)
const height = (d[8] | d[9] << 8) & 0x3fff; // 180
Everything after byte 10 is boolean-coded: an arithmetic coder where each bit is decoded against a probability. Header fields use probability 128, a fair coin, which is why some tools read them as if they were plain bits. That shortcut is right most of the time. The real decoder is short enough that there is no reason to guess:
function boolDecoder(u8, pos) {
let value = (u8[pos++] << 8) | u8[pos++], range = 255, bits = 0;
const bool = (prob) => {
const split = 1 + (((range - 1) * prob) >> 8);
let bit = 0;
if (value >= split << 8) { bit = 1; range -= split; value -= split << 8; }
else range = split;
while (range < 128) { // renormalise, pulling in a byte every 8 shifts
value <<= 1; range <<= 1;
if (++bits === 8) { bits = 0; value |= u8[pos++] ?? 0; }
}
return bit;
};
const literal = (n) => { let v = 0; while (n--) v = (v << 1) | bool(128); return v; };
return { bool, literal };
}
With it you read the frame header in order (RFC 6386 §9.2–9.6, §19.2): colour space, clamping, then segmentation. libwebp splits the picture into up to four segments of macroblocks and gives each its own quantizer, spending more bits where the eye notices. Then the loop filter, the number of token partitions, and the quantizer indices. The sample's first frame has four segments with quantizers 45 41 35 30, covering roughly 3%, 8%, 30% and 59% of the picture. You can stop there; the next field is a table of 1,056 probability updates, and nothing after it is needed to describe the file.
Reading the quality back
A WebP does not store “quality 60”. It stores the quantizers that number produced. But libwebp turns quality into quantizers with a fixed formula, so it can be run backwards. In src/enc/quant_enc.c, quality is mapped to a compressibility that is linear in two pieces, joined at 75; its cube root is c; and the quantizer is 127 × (1 − c). Inverting that:
function estimateQuality(q) { // q: a quantizer index, 0–127
const c = 1 - (q === 0 ? 0 : q + 0.5) / 127; // +0.5 undoes the encoder's truncation
const linear = c ** 3;
return Math.round(100 * (linear < 0.5 ? linear * 1.5 : (linear + 1) / 2));
}
With segments there are four quantizers, not one. libwebp derives each segment's c from the base as cbase1 − k·α, and centres the per-segment α on the population-weighted mean. So averaging log c over the segments, weighted by how much of the image each covers, gives cbase back. The weights are in the file already: the segment map is coded with three probabilities that are the segments' shares. For the sample's frame that lands on 60, which is exactly the -q it was encoded with. Measured against cwebp across the range on three different images, the estimate is within one point of the true -q with noise shaping off, and reads at most two or three points high with it on (the default). On one of those images:
cwebp -q | 10 | 30 | 50 | 70 | 75 | 85 | 95 | 100 |
|---|---|---|---|---|---|---|---|---|
| estimate | 11 | 31 | 51 | 71 | 75 | 85 | 95 | 100 |
That is enough to make a useful decision. Converting a WebP saved at 60 into a JPEG at 95 makes a bigger file, not a better one. The CONVERT tab starts the JPEG slider at the source's quality for exactly that reason.
Lossless: VP8L
A lossless WebP uses a different codec entirely, with its own bitstream that is read from the low bit up. After the signature byte 0x2f come 14 bits of width minus one, 14 bits of height minus one, one bit saying whether alpha is used and three bits of version (always 0). Then a chain of optional transforms, each announced by a 1 bit:
const TRANSFORMS = ['predictor', 'cross-color', 'subtract-green', 'color-indexing'];
while (bits.read(1)) {
const type = TRANSFORMS[bits.read(2)];
if (type === 'subtract-green') continue; // carries no data
if (type === 'color-indexing') return { type, colors: bits.read(8) + 1 };
return { type, block: 1 << (bits.read(3) + 2) }; // predictor / cross-color
}
Three of the four transforms are followed by an entropy-coded sub-image that only a full decoder can skip, so a header reader stops at the first of them. That is still useful: if the first transform is colour indexing, the image is a palette image, and the eight bits after it say how many colours. A 16-colour lossless WebP is a very different file from a photographic one, and converting it to PNG will not keep it as small.
Alpha on a lossy still lives in a separate ALPH chunk. Its first byte gives the compression (raw, or a headerless VP8L stream), a filter, and a pre-processing bit. When that bit says level reduction, the alpha values were quantised before coding: the transparency itself is lossy, which is what cwebp -alpha_q below 100 does.
Animation: a canvas and a list of patches
An animated WebP is the extended layout with an ANIM chunk (a background colour and a loop count; the sample's is ff ff ff ff 00 00, white and loop forever) and one ANMF chunk per frame. Each ANMF starts with a 16-byte header and then contains the frame's own ALPH/VP8 or VP8L chunks:
const frame = {
x: u24(d, 0) * 2, // stored halved: frames sit on even coordinates
y: u24(d, 3) * 2,
width: u24(d, 6) + 1,
height: u24(d, 9) + 1,
duration: u24(d, 12), // milliseconds; the sample's first frame: 0x000258 = 600
blend: d[15] & 2 ? 'replace' : 'alpha-blend',
dispose: d[15] & 1 ? 'clear its rectangle' : 'keep',
};
Most frames after the first are small rectangles that repaint only what changed, so frame 12 on its own is a patch, not a picture. To show frame 12 you composite frames 0 to 12 in order: before drawing frame i, clear frame i−1's rectangle if its dispose rule says so; then draw frame i over the canvas, or replace its rectangle outright if blending is off. The ANIM background colour is officially a hint, and browsers composite onto transparent.
Decoding a frame needs no VP8 decoder of our own. Every browser already ships one, so OmniViewer cuts the frame's image chunks out of the file and wraps them in the smallest valid WebP around them: a 12-byte RIFF header, plus a VP8X naming the frame's size when it has an ALPH chunk (alpha is only legal in the extended layout):
function frameAsWebp(frame, file) {
const extended = !!frame.alph;
const parts = [frame.alph, frame.image].filter(Boolean)
.map((c) => file.slice(c.start, c.start + c.size)); // the frame's own chunks
const body = parts.reduce((n, p) => n + p.size, 0);
const head = new Uint8Array(extended ? 30 : 12);
head.set([0x52, 0x49, 0x46, 0x46]); // "RIFF"
new DataView(head.buffer).setUint32(4, 4 + (extended ? 18 : 0) + body, true);
head.set([0x57, 0x45, 0x42, 0x50], 8); // "WEBP"
if (extended) { /* "VP8X", size 10, flags 0x10, width − 1, height − 1 */ }
return createImageBitmap(new Blob([head, ...parts], { type: 'image/webp' }));
}
That is the whole FRAMES tab: decode one frame on demand, composite, and keep a few snapshots of the canvas so stepping backwards does not mean replaying from the start. Its result is checked against Chromium's own animation decoder, frame by frame, in the test suite. The same compositor feeds the animated-PNG export, which writes each composited frame as one APNG frame. Only the rectangle that changed is stored, which is exact because each APNG frame replaces its rectangle and nothing is ever cleared.
EXIF, XMP and C2PA
Metadata in a WebP is three chunks. EXIF holds the same TIFF structure a JPEG's APP1 does (camera, timestamps and, from a phone, the GPS position); some writers keep JPEG's Exif\0\0 prefix in front of it, so a reader should accept both. XMP is an RDF/XML packet, often with a second copy of the location. And a C2PA chunk carries a signed content credential, which is where image generators record that a model made the picture.
Removing them is a rewrite, not a re-encode. Copy every chunk except those three, fix the RIFF size, and clear the EXIF and XMP bits in VP8X so the header stops promising chunks that are gone. The image chunks are copied byte for byte, so the picture is identical. Built as a list of byte ranges of the original file plus a few fresh header bytes, the browser can stream the result to disk without holding the file in memory.
How big a WebP can get
Every RIFF size is 32 bits, so a WebP ends at 4 GiB; there is no 64-bit extension like WAV's RF64. VP8 and VP8L store width and height in 14 bits, so no frame is wider or taller than 16,383 pixels. Within those limits a WebP gets big in two ways, a huge lossless still or a long animation, and OmniViewer reads both index-then-seek:
- The chunk walk reads 8 bytes per chunk plus the 16-byte header and the first few dozen bytes of each frame. A run of small frames shares one 256 KB read; once the walk is stepping over large payloads it reads only what it needs, so big frames cost a few hundred bytes each rather than a full window.
- The result is an index of numbers: offset, rectangle, timing, codec and quality for every frame. A 50,000-frame animation is indexed without decoding a pixel.
- Frames are decoded one at a time, when you look at them. A seek starts from the nearest key frame (one that redraws the whole canvas on its own; libwebp's rule, and its encoder inserts them every few frames) or the nearest cached snapshot of the canvas. Snapshots are capped at 128 MB, so a large canvas keeps fewer of them.
- The worst case is an animation with no key frame after the first: the first jump to the end has to draw every frame. On a 54,468-frame test file that took 22 seconds, with a progress line; every seek after it took under 70 ms. Our 118,796-frame, 1 GB test file (all key frames) indexed in 1.8 seconds with a 38 MB JavaScript heap.
- The clean copy is byte ranges of the original file, so cleaning a 3 GB animation costs the same memory as cleaning a 3 KB icon.
If a file is larger than its RIFF says, because something was appended or because it is over 4 GiB and the size could not say so, the bytes past the end are reported and left out of the clean copy.
Where the spec lives
The container and the lossless bitstream are specified in RFC 9649, WebP Image Format (2024). The lossy bitstream is VP8, RFC 6386, of which a WebP uses one key frame. The quality curve is not in any spec; it is in libwebp's encoder source, which every mainstream encoder links. None of it needs a library to read. A few hundred lines of plain JavaScript walk the chunks, run the boolean decoder and composite the frames, which is what the OmniViewer WebP toolkit does in your browser.