Animated images (GIF and WebP) and the toilet-roll frame model
libvips does not use a separate 3D frame-array object for animations. Instead, when loading Animated GIF or Animated WebP files with n=-1 (NumPages: -1 or animated: true), libvips stacks every frame vertically along the Y axis into a single tall 2D strip — known in libvips documentation as a “toilet-roll” image.
1. Dimensions and metadata of a toilet-roll image
Section titled “1. Dimensions and metadata of a toilet-roll image”Suppose an animated GIF has a single-frame canvas of 400 × 300 and contains 12 frames:
- Default load (
n=1): decodes only frame 0 (Width = 400,Height = 300). - All-frame load (
n=-1): stacks all 12 frames vertically:Width = 400Height = 3600(300 × 12)PageHeight = 300(stored inpage-heightmetadata to record the height of a single frame)Pages = 12(total frame count in the source file)PageDelay(per-frame duration array in milliseconds) andLoop(0means infinite looping)
| Operation or property | govips (v2.20.0) |
sharp (0.35.5) |
pyvips (3.2.0) |
vips CLI (8.17.3) |
|---|---|---|---|---|
| Load all animation frames | ImportParams.NumPages.Set(-1) |
sharp(input, { animated: true }) |
new_from_file("in.gif", n=-1) |
'in.gif[n=-1]' |
| Read single-frame height | image.PageHeight() |
meta.pageHeight |
image.get("page-height") |
vipsheader -f page-height |
| Read/write frame delays | image.PageDelay() / SetPageDelay |
meta.delay / webp({ delay }) |
image.get("delay") |
— |
| Split and rejoin frames | Built into multi-page-aware methods | Automatic via pageHeight |
image.pagesplit() / Image.pagejoin(frames) |
— |
2. Resizing and transcoding Animated GIF to Animated WebP
Section titled “2. Resizing and transcoding Animated GIF to Animated WebP”Animated WebP supports 24-bit RGB plus an 8-bit alpha channel, typically reducing file size by 50%–80% compared to 256-colour GIFs while eliminating dithering grain.
In govips v2.20.0, ExtractArea, Resize/ResizeWithVScale, Embed, Zoom, Flip, and 90/180/270-degree Rotate preserve frame order and page-height on loaded multi-page strips. Version 2.20 fixes frame-geometry failures in Rotate, Flip, Zoom, and scaling to non-integer per-frame heights. This does not make Similarity, SmartCrop, or Gravity frame-safe. In pyvips 3.2.0, use pagesplit() and pagejoin() when an operation is not explicitly multi-page-aware:
package main
import ( "fmt" "os"
"github.com/davidbyttow/govips/v2/vips")
func main() { if err := vips.Startup(nil); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } defer vips.Shutdown()
// 1. Set NumPages = -1 to load all animation frames importParams := vips.NewImportParams() importParams.NumPages.Set(-1)
anim, err := vips.LoadImageFromFile("animated.gif", importParams) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } defer anim.Close()
fmt.Printf("Total strip: %dx%d, single frame PageHeight=%d, Pages=%d\n", anim.Width(), anim.Height(), anim.PageHeight(), anim.Pages())
// 2. Resize by 50%: govips Resize is multi-page-aware and updates PageHeight if err := anim.Resize(0.5, vips.KernelLanczos3); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) }
// 3. Export as Animated WebP (preserving frame delays and loop count) webpParams := vips.NewWebpExportParams() webpParams.Quality = 80 webpParams.ReductionEffort = 4
data, _, err := anim.ExportWebp(webpParams) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } if err := os.WriteFile("animated.webp", data, 0o644); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) }}import sharp from 'sharp';
// animated: true (or pages: -1) loads all frames; resize and webp preserve pageHeight and frame delaysawait sharp('animated.gif', { animated: true }) .resize({ width: 320 }) .webp({ quality: 80, effort: 4, loop: 0, // 0 = infinite loop }) .toFile('animated.webp');import pyvips
# Approach A: Image.thumbnail is natively page-height-awareanim = pyvips.Image.thumbnail("animated.gif[n=-1]", 320)anim.write_to_file("animated.webp", Q=80, effort=4)
# Approach B: Use pyvips 3.2.0 pagesplit() / pagejoin() for per-frame croppingfull = pyvips.Image.new_from_file("animated.gif", n=-1)frames = [frame.crop(10, 10, 200, 150) for frame in full.pagesplit()]rejoined = pyvips.Image.pagejoin(frames)rejoined.write_to_file("cropped-animated.webp", Q=80)#!/usr/bin/env bashset -euo pipefail
# thumbnail natively handles [n=-1] animations and updates page-height metadatavips thumbnail 'animated.gif[n=-1]' 'animated.webp[Q=80,effort=4]' 3203. Common animation pitfalls and defences
Section titled “3. Common animation pitfalls and defences”- Calling raw
cropon a multi-frame strip without updatingpage-height:- In
pyvipsor thevips CLI, callingimage.crop(left, top, width, height)directly on ann=-1strip slices only the top region of the strip and leavespage-heightinconsistent (often failing on export withImage height is not a multiple of page-height). Usepagesplit()andpagejoin()inpyvips, or rely ongovips v2.20.0’s multi-page-awareExtractArea.
- In
- Confusing total strip
Height()with single-framePageHeight():- A
500 × 500animation with100frames hasHeight() == 50,000when loaded withn=-1. Dimension guards that checkHeight()after loading will falsely reject normal animations, whereas forgetting to budgetWidth × PageHeight × Pagesexposes your service to thousand-frame memory bombs (see Handling untrusted inputs).
- A
- CPU cost of exporting back to GIF:
- Exporting truecolour frames to
.gif(ExportGIF/.gif()/gifsave) invokes palette quantization viacgif, which costs significantly more CPU than exporting Animated WebP.
- Exporting truecolour frames to