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
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
| 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
-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}'
6311078 m.gif
176067 m.mp4
273510 m.webm
1636955 t.gif
149248 t.mp4
173603 t.webm
| 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
yuv444p
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
yuv420p,15/1
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 yuv420pcame 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.]*'
crf23 242641 All:0.997017
crf28 149248 All:0.991398
crf34 76636 All:0.979314
All:0.960837 # the GIF
| 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>
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>
// 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();
})();
$ node check-autoplay.js
[{"i":"a","paused":false,"t":1.5},{"i":"b","paused":true,"t":0}]
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>
// 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));
โ ๏ธ Note: I did not run this script in a browser; it follows web.dev's pattern but treat it as untested. Keep
controlsin 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
ffprobepixel-format check to CI. - Decide where GIFs must stay: email and markdown READMEs often can't take a video element.
Top comments (0)