DEV Community

Cover image for Exercise animations in a fitness app: transparent WebP or MP4? I measured both
sergei argutin
sergei argutin

Posted on • Originally published at repdb.co

Exercise animations in a fitness app: transparent WebP or MP4? I measured both

Animated WebP or MP4 for short looping animations? Most write-ups end with "it
depends, measure it", so I measured it on the 442 exercise clips I ship, and
the useful answer turned out not to be a winner but an order of operations.

The asymmetry decides it:

  • From a transparent clip you can produce anything — composite any background, export MP4, resize per platform, re-theme next quarter. One build command, at the bottom of this post.
  • From an opaque clip you cannot get the background back. Resize and re-encode still work, but the panel colour is in every pixel: no new theme, no round container, no clean transparency without re-rendering.

So the transparent WebP is the master, and MP4 is a delivery format you
generate from it. Here is what each costs.

The measured comparison

Six clips spanning the size range of the catalogue. Every variant re-encoded
from the same source with the same settings (quality 90, method 4, exact
alpha); MP4 is H.264 crf 23, medium preset, composited on #111827:

Clip WebP 960, transparent Same, composited (opaque) MP4 crf 23 MP4 crf 28
Single Leg Glute Bridge 1232 KB 474 KB 71 KB 43 KB
Mountain Climbers 1316 KB 522 KB 100 KB 61 KB
Side-Lying Lateral Raise 2186 KB 1049 KB 94 KB 51 KB
Incline Dumbbell Curl 3387 KB 1381 KB 128 KB 65 KB
One Arm Kettlebell Push Press 4093 KB 1435 KB 152 KB 86 KB
Battle Ropes 5022 KB 1841 KB 419 KB 214 KB

Read it left to right: one master and two things you can make from it, not
three products to choose between.

Fixing the background saves ~2.5x. Same format, same settings, alpha gone —
the median file drops to 39%. That is what the alpha channel costs.

Encoding as video saves another ~8x, because a video codec has interframe
prediction and an animated image format does not. End to end: 3387 KB → 128 KB
for one clip, from a file you already have.

The master is the biggest file in the table. That is what a master is for.

What transparency buys

Not aesthetics — options you would otherwise have to buy again:

  • Themes as a feature. If users pick or purchase a theme, every asset has to look right on all of them. A transparent figure composites onto whatever the theme sets; an opaque one needs an export per theme, from whoever owns the source artwork.
  • White-label. The same catalogue in a client's palette, without re-cutting a file.
  • Dark mode, which is the same problem and catches most teams after launch.
  • Any container — round avatars, rounded cards, overlays on a photo.
  • MP4 on demand. Pick the background, run the recipe below, ship video where it helps, regenerate it at the next redesign.

Same movement, both ways, on two surfaces:

The same animation as a transparent WebP and as an MP4 with a dark panel baked in, each shown on a dark panel and a light card. The MP4 is a hard dark rectangle on the light card.

The video is a fraction of the bytes and completely correct — on the panel it
was exported for. Move it to a light card and it is simply wrong. Fine, as long
as it is a derivative you can regenerate rather than the only copy you own.

What MP4 costs you back

A twelve-card grid, three ways, every tile at 240 CSS px, Chrome on a desktop
over localhost:

Grid of 12 Bytes Load Sustained frame rate
480px stills 0.26 MB 54 ms 56 fps
960px animated WebP 37.92 MB 83 ms 56 fps
MP4, background baked in 1.97 MB 296 ms 28 fps

The MP4 grid moved 20x fewer bytes and ran at half the frame rate: twelve
<video> elements decoding and compositing at once is real work. Pausing
eleven put it back to 56 fps; resuming them dropped it to 28 again.

The WebP row needs a warning label

That 56 fps is the page's frame rate from requestAnimationFrame. An
animated image decodes and rasters outside the page's animation loop, so a clip
can drop half its frames while the page reports a calm 56 — and no browser API
exposes an image's real playback rate. Drawing it to a canvas returns a frozen
frame, not the current one.

The decode work can be measured, with the browser's own ImageDecoder:

const buf = await (await fetch(url)).arrayBuffer();
const dec = new ImageDecoder({ data: buf, type: 'image/webp' });
await dec.tracks.ready;
await dec.completed;

const frames = dec.tracks.selectedTrack.frameCount;
const t0 = performance.now();
for (let i = 0; i < frames; i++) {
  const { image } = await dec.decode({ frameIndex: i });
  image.close();
}
console.log((performance.now() - t0) / frames, 'ms per frame');
Enter fullscreen mode Exit fullscreen mode

Four clips, every frame, best of three passes:

Clip Frames 960 px 480 px
Mountain Climbers 34 4.48 ms/frame 0.82 ms/frame
Battle Ropes 99 4.61 ms/frame 0.95 ms/frame
Incline Dumbbell Curl 60 5.16 ms/frame 1.09 ms/frame
Side-Lying Lateral Raise 41 5.41 ms/frame 1.07 ms/frame

At the clips' own 20 fps that is 90–108 ms of decode per second of playback
for a 960px clip — a tenth of a core, per clip, on a fast desktop. At 480px,
16–22 ms.

Twelve of them need ~1.1 s of decode per second of wall clock. Nothing plays
correctly under that. The page stayed at 56 fps because the animations, not the
page, absorbed the shortfall — where the metric could not see it.

So:

  • MP4 is far cheaper to download, rides the video pipeline, and gets expensive in bulk — visibly, in the page's frame rate.
  • Animated WebP is heavier to download, costs ~10% of a core per master clip to play, and degrades invisibly.
  • A still beats both wherever nobody is studying the motion.

Whatever you pick, play one clip at a time. That single rule outperforms the
format choice.

Converting: the recipe that actually works

This is where the standard advice fails. The obvious command —

# does NOT work on these clips
ffmpeg -i animation.webp ... output.mp4
Enter fullscreen mode Exit fullscreen mode

— fails on FFmpeg 8.1 with image data not found. FFmpeg only learned to
decode animated WebP in 7.1, and its decoder still chokes on clips whose frames
are stored as blended sub-rectangles rather than full canvases, which is what a
size-optimised encoder produces. Every clip I tried failed the same way.

Extract the frames with libwebp's anim_dump first — it composites each frame
onto the full canvas — then encode the PNG sequence:

CLIP=animation.webp

# 1. frames out (libwebp ships anim_dump alongside cwebp)
mkdir -p frames
anim_dump -folder frames "$CLIP"

# 2. the clip's own rate rather than a guess: frames / total duration
FPS=$(webpinfo -summary "$CLIP" \
  | awk '/Duration/ {n++; ms += $2} END {printf "%.0f", n / (ms / 1000)}')

# 3. composite on the panel colour and encode
ffmpeg -y \
  -framerate "$FPS" -i "frames/dump_%04d.png" \
  -f lavfi -i "color=c=#111827:s=960x960:r=$FPS" \
  -filter_complex "[1:v][0:v]overlay=shortest=1:format=auto,format=yuv420p[v]" \
  -map "[v]" -an \
  -c:v libx264 -crf 23 -preset medium -movflags +faststart \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

Three things that bite:

  • Frame rate. The colour source dictates the output rate through the overlay, so both -framerate and r= must match the clip. Assume 25 and you ship duplicated frames.
  • Alpha edges. Check the result on a light and a dark background. Incorrect compositing shows up as a pale fringe around thin objects.
  • crf. 23 is a safe default; 28 roughly halved the files again with no obvious loss at card size — flat illustration compresses well.

Choosing between them

Requirement Choice
Asset must sit on light, dark, and brand surfaces Animated WebP
One fixed background across the product MP4 derivative
Display with a plain <img> Animated WebP
Play, pause, seek, visibility control MP4
Several clips visible at once Neither — stills, one active clip
Bandwidth-critical, single active clip MP4
A master you can re-theme without a transcode step Animated WebP

Method

Six clips from the current build, 1.3–5.1 MB, covering the size range of 442
shipped animations. WebP variants re-encoded at quality 90, method 4,
exact=True, disposal 2. MP4 encoded with FFmpeg 8.1, libx264, crf 23/28,
medium preset. Grid test: Chrome on a desktop, twelve cards at 240 CSS px in
a four-column grid, served over localhost, frame rate sampled over three
seconds via requestAnimationFrame. Localhost removes the network, which is
why the byte column deserves more weight than the load column. Decode cost:
Chrome's ImageDecoder, file already in memory, so it isolates decode from
network and layout.

Reading an animated image's actual on-screen frame rate is not possible from
page JavaScript. Where this post says an animation stutters, that is an
observation; the decode budget is the measurement that explains it.

Disclosure

The clips are from RepDB, an exercise dataset I build and sell. It ships the thing this post argues for: looping exercise animations at 960px on transparent backgrounds, plus stills in two styles, as files you own. That is why both columns of the table came from one source — I had the master, so the opaque and MP4 variants were an export rather than a re-render. Every other illustrated exercise set I looked at ships opaque frames — GIF or photography — with the background painted in, which is the one property you cannot get back.

The original post has the same measurements with the figures running live, and the free preview bundle is there if you would rather benchmark the actual files than take my table for it.

Top comments (0)