DEV Community

Mason K
Mason K

Posted on

Replace animated GIFs with MP4 and WebM: FFmpeg commands, size comparison, and the yuv444p trap

TL;DR

We'll convert a GIF to MP4 and WebM with FFmpeg, compare file sizes, ship the HTML that makes a video behave like a GIF, and catch the pixel-format flag that decides whether iPhones can play it.

๐Ÿ“ฆ Code: github.com/USER/gif-to-video (replace before publishing)

Versions used: ffmpeg 6.1.1 with libx264 and libvpx-vp9, ffprobe, any modern browser. #ffmpeg #webdev

1. ๐ŸŽฅ Make some test GIFs

Two synthetic 640x360, 15 fps, 4-second clips. The mandelbrot is a worst case for GIF; testsrc2 is friendlier.

# make-gifs.sh
ffmpeg -y -f lavfi -i "mandelbrot=size=640x360:rate=15" -t 4 -pix_fmt yuv420p src_m.mp4
ffmpeg -y -f lavfi -i "testsrc2=size=640x360:rate=15"   -t 4 -pix_fmt yuv420p src_t.mp4

for n in m t; do
  ffmpeg -y -i src_$n.mp4 \
    -vf "fps=15,split[a][b];[a]palettegen[p];[b][p]paletteuse" $n.gif
done
Enter fullscreen mode Exit fullscreen mode

The palettegen/paletteuse pair builds a custom 256-colour palette, which is the best case for the GIF side of the comparison.

2. ๐Ÿ“ฆ Convert to MP4 (H.264)

# to-mp4.sh
ffmpeg -y -i $1 -c:v libx264 -crf 28 -preset slow \
  -pix_fmt yuv420p -movflags +faststart -an ${1%.gif}.mp4
Enter fullscreen mode Exit fullscreen mode
Flag Why
-crf 28 quality target; lower number means bigger and better
-pix_fmt yuv420p the compatibility flag (see step 5)
-movflags +faststart metadata at the front so playback can start early
-an no audio track

3. ๐Ÿ“ฆ Convert to WebM (VP9)

# to-webm.sh
ffmpeg -y -i $1 -c:v libvpx-vp9 -crf 36 -b:v 0 -an ${1%.gif}.webm
Enter fullscreen mode Exit fullscreen mode

-b:v 0 with -crf puts libvpx-vp9 in constant-quality mode. Without it the CRF value is capped by a default bitrate.

4. ๐Ÿ“Š Compare

ls -l m.gif m.mp4 m.webm t.gif t.mp4 t.webm | awk '{print $5, $9}'
Enter fullscreen mode Exit fullscreen mode
6311078 m.gif
176067 m.mp4
273510 m.webm
1636955 t.gif
149248 t.mp4
173603 t.webm
Enter fullscreen mode Exit fullscreen mode
Clip GIF MP4 (CRF 28) WebM (CRF 36)
mandelbrot 6.3 MB 176 KB 273 KB
testsrc2 1.6 MB 149 KB 174 KB

โš ๏ธ Note: these are synthetic clips and the CRF values for H.264 and VP9 are not matched for visual quality, so don't read MP4 vs WebM as a codec verdict. The GIF vs video gap is the finding. Run this on one of your own GIFs for a real number.

5. ๐Ÿงฏ The yuv444p trap

I converted a GIF without -pix_fmt to see what happens:

ffmpeg -y -i m.gif -c:v libx264 -t 1 nopix.mp4
ffprobe -v error -show_entries stream=pix_fmt -of csv=p=0 nopix.mp4
Enter fullscreen mode Exit fullscreen mode
yuv444p
Enter fullscreen mode Exit fullscreen mode

With the flag:

ffmpeg -y -i m.gif -c:v libx264 -crf 28 -pix_fmt yuv420p -movflags +faststart -an -t 1 ok.mp4
ffprobe -v error -show_entries stream=pix_fmt,r_frame_rate -of csv=p=0 ok.mp4
Enter fullscreen mode Exit fullscreen mode
yuv420p,15/1
Enter fullscreen mode Exit fullscreen mode

The yuv444p file is valid H.264, but many mobile and hardware decoders only handle yuv420p, so it plays on your laptop and fails in the field. (I did not test devices here; this is the widely reported behaviour, so check on a real phone.) Put the ffprobe line in CI and fail the build if pix_fmt is not yuv420p.

๐Ÿ’ก Tip: odd dimensions. A 641x359 source encoded with -pix_fmt yuv420p came out as 640x358 on ffmpeg 6.1.1, silently. If the frame size matters, scale explicitly: -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2".

5b. ๐Ÿ” Check quality, not just size

File size alone can hide a bad encode. Use FFmpeg's ssim filter against the original (1.0 is identical):

# ssim.sh
for c in 23 28 34; do
  ffmpeg -v error -y -i src_t.mp4 -c:v libx264 -crf $c -preset slow -pix_fmt yuv420p -an c$c.mp4
  echo "crf$c $(stat -c %s c$c.mp4) $(ffmpeg -i c$c.mp4 -i src_t.mp4 -lavfi ssim -f null - 2>&1 | grep -o 'All:[0-9.]*')"
done
# and the GIF, converted to yuv420p for the comparison
ffmpeg -i t.gif -i src_t.mp4 -lavfi "[0:v]format=yuv420p[a];[a][1:v]ssim" -f null - 2>&1 | grep -o 'All:[0-9.]*'
Enter fullscreen mode Exit fullscreen mode
crf23 242641 All:0.997017
crf28 149248 All:0.991398
crf34 76636 All:0.979314
All:0.960837   # the GIF
Enter fullscreen mode Exit fullscreen mode
Encode Size SSIM
GIF 1.6 MB 0.961
H.264 CRF 34 77 KB 0.979
H.264 CRF 28 149 KB 0.991
H.264 CRF 23 243 KB 0.997

On this test pattern every H.264 setting is both smaller and closer to the source than the GIF. SSIM is a rough metric (vmaf is better if your FFmpeg build has it), so use it as a smoke test, then look at the clip.

6. ๐ŸŒ The HTML

web.dev's GIF replacement pattern is autoplay muted loop playsinline, and playsinline is necessary for autoplay on iOS.

<!-- index.html -->
<video autoplay muted loop playsinline width="640" height="360" poster="/m-poster.webp">
  <source src="/m.webm" type="video/webm">
  <source src="/m.mp4" type="video/mp4">
</video>
Enter fullscreen mode Exit fullscreen mode

Muted autoplay is always allowed in Chrome (policy introduced in Chrome 66), which is why muted is required rather than optional.

Order the sources so the format you want browsers to prefer comes first; each browser takes the first one it can play.

Verify the autoplay rule in a browser

Two videos, identical except for muted:

<!-- ap.html -->
<video id="a" src="/fast.mp4" autoplay muted loop playsinline width="320"></video>
<video id="b" src="/fast.mp4" autoplay loop playsinline width="320"></video>
Enter fullscreen mode Exit fullscreen mode
// check-autoplay.js
const { chromium } = require('playwright');
(async () => {
  const br = await chromium.launch();
  const pg = await br.newPage();
  await pg.goto('http://127.0.0.1:8801/ap.html');
  await pg.waitForTimeout(1500);
  console.log(JSON.stringify(await pg.evaluate(() =>
    ['a', 'b'].map(i => { const v = document.getElementById(i);
      return { i, paused: v.paused, t: +v.currentTime.toFixed(2) }; }))));
  await br.close();
})();
Enter fullscreen mode Exit fullscreen mode
$ node check-autoplay.js
[{"i":"a","paused":false,"t":1.5},{"i":"b","paused":true,"t":0}]
Enter fullscreen mode Exit fullscreen mode

The muted video plays; the unmuted one never starts. That is Chromium 153 headless, matching the documented policy.

7. ๐Ÿงช Below the fold: lazy-start the video

web.dev's fallback for browsers without loading="lazy" on video: start with preload="none" and a poster, leave out autoplay, and turn it on with an IntersectionObserver.

<video class="lazy" muted loop playsinline preload="none"
       poster="/m-poster.webp" width="640" height="360" controls>
  <source data-src="/m.mp4" type="video/mp4">
</video>
Enter fullscreen mode Exit fullscreen mode
// lazy-video.js
const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    const v = e.target;
    for (const s of v.querySelectorAll('source')) s.src = s.dataset.src;
    v.load();
    v.play().catch(() => {});
    v.removeAttribute('controls');
    io.unobserve(v);
  }
});
document.querySelectorAll('video.lazy').forEach(v => io.observe(v));
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ Note: I did not run this script in a browser; it follows web.dev's pattern but treat it as untested. Keep controls in the markup so users still have a play button if JavaScript fails.

โš ๏ธ Note: do not lazy-load a video that is your Largest Contentful Paint element; the previous post in this series covers that.

What's next

  • Run the conversion on the five largest GIFs in your repo and compare sizes.
  • Add the ffprobe pixel-format check to CI.
  • Decide where GIFs must stay: email and markdown READMEs often can't take a video element.

Top comments (0)