Skip to content
All documentation pages

Animation: animated PNG, frames and GIF loops

Animate a render: format=apng returns the whole pose cycle as an animated PNG, with frames, fps and loop, and stepping frame yourself still builds a GIF.

Two ways to animate

The animated poses are cycles: walk, run, dance, wave each run from one end of a loop to the other as frame goes from 0 to 1. There are two ways to turn that into something that moves.

  • Ask for the whole loop at once with format=apng. One request, one file, an animated PNG you can put straight in an <img>.
  • Render the frames yourself by stepping frame, and assemble them with whatever encoder you already have. More requests, more code, and still the only way to get a GIF, because the API does not produce GIFs.

If you want a moving image on a web page, use the first. If you need a GIF specifically — an old forum, a signature image, a chat platform that will not play APNG — use the second.

format=apng

format=apng on a bust or body render returns an animated PNG of the entire pose cycle rather than a single frame. format=png is the default and is exactly what it always was: one still image, same bytes as before.

text
https://skinrender.dev/render/Notch/body?pose=walk&format=apng
https://skinrender.dev/render/Notch/body.apng?pose=walk

The two lines above are the same request. The .apng ending is the animated counterpart of the .png ending on a still render: it exists for the forum tags, webhooks and site builders that will not accept a URL unless it ends in a file extension, and it means exactly format=apng.

An animated PNG is a PNG. Every browser released in the last several years plays one in an ordinary <img> tag, and anything that does not play it shows the first frame instead of breaking.

html
<img src="https://skinrender.dev/render/853c80ef3c3749fdaa49938b674adae6/body.apng?pose=run&size=256"
     width="160" height="256" alt="jeb_ running" loading="lazy">

frames, fps and loop

Three parameters describe the animation itself. They mean nothing to a still render and are ignored there.

ParameterRangeDefaultWhat it does
format png | apng png Whether the response is a single frame or the whole cycle as an animated PNG.
frames 2 – 60 16 How many frames one loop is sampled at. More frames are smoother and larger; the cycle takes the same time either way.
fps 1 – 50 20 Playback rate. With the defaults, 16 frames at 20 fps is a loop of 0.8 seconds.
loop 0 – 100 0 How many times it plays before stopping. 0, the default, loops forever.

The loop length in seconds is frames ÷ fps, so a slower walk is fps=10 rather than more frames, and a smoother one at the same speed is frames=32&fps=40.

text
https://skinrender.dev/render/Notch/body?pose=walk&format=apng&frames=24&fps=24
https://skinrender.dev/render/Notch/bust?pose=wave&format=apng&loop=3&size=256

What can be animated

  • Only the animated poses may be animated: walk, run, dance, wave. Every other pose is a fixed set of angles with nothing to cycle through, so format=apng on one of them is a 400 rather than a file with sixteen identical frames in it.
  • Only bust and body, the two render types that have a pose. The flat types have no pose at all, and a head is a single box with nothing to swing, so head.apng is a 400 that says so.
  • frame is ignored with format=apng. The response is the whole cycle, so asking for one phase of it means nothing.
  • fit defaults to false with format=apng, the opposite of its default on a still render. Auto-fit frames each image to fill the canvas on its own, which makes a swinging arm change the scale from frame to frame and the model appear to jitter; with it off the camera is fixed for the whole loop. Pass fit=true if you want the jitter.

Everything else — size, bg, the camera, the layer toggles, the per-joint overrides — applies to every frame, exactly as it applies to a still render.

Animating a mob

The mob renders take format=apng, frames, fps and loop in exactly the same way, on the body render with a pose the mob declares as a cycle. Each mob has its own list — a chicken flaps, a wolf does not — and its page says which of its poses cycle.

text
https://skinrender.dev/render/mob/creeper/body.apng?pose=walk
https://skinrender.dev/render/mob/zombie/body.apng?pose=walk
https://skinrender.dev/render/mob/skeleton/body.apng?pose=walk

A mob head render is refused, the way a player head is: the head may bob within a walk cycle, but a file of sixteen nearly identical frames is not what the request meant. Use frame on the head render if you do want that one phase.

Stepping frame yourself

The older approach, and still the right one when you need a specific container: request each frame as its own PNG and assemble them. frame is the phase of the cycle from 0 to 1, frame=1 is the same picture as frame=0, and the frames of an n-frame loop are therefore 0, 1/n, 2/n ….

Pass fit=false on every frame. This is the same reason format=apng turns it off by default: with auto-fit on, each frame is scaled individually and the animation wobbles.

text
https://skinrender.dev/render/069a79f444e94726a5befca90e38aaf5/body?size=256&fit=false&pose=walk&frame=0.25

Each frame is an ordinary cacheable PNG, so re-assembling the animation later costs nothing, and a sprite sheet is the same loop laid out in a row instead of stacked in time.

Making a GIF

The API returns PNG frames and animated PNGs; it does not return GIFs. Encoding one is a step you run yourself, with a tool you almost certainly already have.

bash
for f in 0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9; do
  curl -s -o "walk-$f.png" \
    "https://skinrender.dev/render/069a79f444e94726a5befca90e38aaf5/body?pose=walk&fit=false&size=256&frame=$f"
done
# ImageMagick (frames sorted numerically; 10 fps; keep transparency)
convert -delay 10 -loop 0 -dispose previous $(ls walk-*.png | sort -V) walk.gif
python
import requests
from PIL import Image
from io import BytesIO

base = "https://skinrender.dev/render/069a79f444e94726a5befca90e38aaf5/body"
frames = []
for i in range(16):
    r = requests.get(base, params={"pose": "run", "fit": "false", "size": 256, "frame": i / 16})
    r.raise_for_status()
    frames.append(Image.open(BytesIO(r.content)).convert("RGBA"))

frames[0].save("run.gif", save_all=True, append_images=frames[1:],
               duration=50, loop=0, disposal=2, transparency=0)

A GIF has 256 colours and one bit of transparency, so a Minecraft skin survives it well but the edges of a supersampled render do not: use aa=1 for a GIF, or accept a fringe. An animated PNG has none of those limits, which is why it is what the API produces.

File size and support

An animated PNG is roughly one frame multiplied by the number of frames, less whatever the encoder saves between them: doubling frames roughly doubles the file, and so does doubling size, since the canvas grows in both directions. Ten to twenty frames is enough for a walk or a run and twenty-four is smoother for a wave or a dance, so the defaults are a deliberate middle. Sixty frames at 1024 px is a large download and not something to put in a page that lists a hundred players.

The animated response is cached and revalidated exactly like a still one: the same URL is the same bytes, it carries an ETag, and the parameters that describe the animation are part of the cache key like every other parameter.

Mob renders take frame in the same way for their own cycling poses — see mob poses — and the poses reference lists which player poses are cycles and what each one does.