Batch convert a folder of images to WebP and AVIF with Node.js (sharp) or FFmpeg
You have a folder of photos and screenshots and you want WebP and AVIF versions of all of them — maybe resized for the web. This post gives you a small Node.js script that does it properly with sharp, then covers SVG to PNG and the FFmpeg one-liners if you'd rather not write any JavaScript. Every command below was run on a stock node:24-slim Docker image (and the script again on node:26-slim), and the output shown is the real output.
Why sharp
sharp wraps libvips, which is fast and memory-frugal, and it ships prebuilt binaries for Linux, macOS and Windows — npm install sharp needs no compiler and no system libraries. It reads JPEG, PNG, WebP, AVIF, GIF, TIFF and SVG, and writes all of those except SVG. This post uses [email protected], which needs Node.js 20.9 or newer.
npm install sharpYou no longer need the glob package to find files: fs.glob from node:fs/promises has been stable since Node.js 24.0.0 and 22.17.0.
The script
Save this as convert.mjs. It walks a folder recursively, writes a .webp and an .avif next to every JPG and PNG, and accepts two flags: --formats (default webp,avif) and --max-width.
import { glob, stat } from 'node:fs/promises';
import path from 'node:path';
import { parseArgs } from 'node:util';
import sharp from 'sharp';
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
formats: { type: 'string', default: 'webp,avif' },
'max-width': { type: 'string' },
},
});
const dir = path.resolve(positionals[0] ?? '.');
const formats = values.formats.split(',');
const maxWidth = values['max-width'] ? Number(values['max-width']) : undefined;
// Per-format encoder settings. These are sharp's defaults, spelled out so
// they're easy to tune.
const encoderOptions = {
webp: { quality: 80 },
avif: { quality: 50 },
};
const kb = (bytes) => `${Math.round(bytes / 1024)} kB`;
// Skip work that's already done: the output exists and is newer than the source.
async function isUpToDate(src, out) {
try {
return (await stat(out)).mtimeMs >= (await stat(src)).mtimeMs;
} catch {
return false;
}
}
async function convert(file) {
const src = path.join(dir, file);
const ext = path.extname(file);
const { size } = await stat(src);
for (const format of formats) {
const out = src.slice(0, -ext.length) + '.' + format;
if (await isUpToDate(src, out)) {
console.log(`skip ${file} → ${format} (up to date)`);
continue;
}
// autoOrient applies the EXIF Orientation tag, so phone photos come out
// the right way up even though the metadata is stripped.
let image = sharp(src, { autoOrient: true });
if (maxWidth) {
image = image.resize({ width: maxWidth, withoutEnlargement: true });
}
const info = await image.toFormat(format, encoderOptions[format]).toFile(out);
console.log(`convert ${file} → ${format} ${info.width}×${info.height} ${kb(size)} → ${kb(info.size)}`);
}
}
// fs.glob is case-sensitive on Linux, so list both spellings.
const pattern = '**/*.{jpg,jpeg,png,JPG,JPEG,PNG}';
const files = await Array.fromAsync(glob(pattern, { cwd: dir }));
// Convert a few files at a time. sharp already spreads each image across
// CPU cores, so a small pool is enough to keep them all busy.
const queue = [...files];
const worker = async () => {
while (queue.length) await convert(queue.shift());
};
await Promise.all(Array.from({ length: 4 }, worker));
console.log(`done: ${files.length} images in ${dir}`);Some details that matter in practice:
- Case-sensitive matching. On Linux,
*.jpgdoes not matchIMG_0042.JPG, the name a phone camera gives a photo. That's why the pattern lists both spellings. autoOrient: truerotates the pixels according to the EXIFOrientationtag. sharp strips metadata from the output by default, so without this a portrait phone photo can come out sideways.withoutEnlargementmakes--max-widtha maximum: images that are already narrower are left at their size, not upscaled.- Skip if up to date. When the output is newer than the source, the file is skipped, so running the script again after adding a few photos only converts the new ones.
- A small worker pool. Four files at a time is plenty. libvips already uses several threads per image, so a bigger pool mostly uses more memory.
Running it
The test folder has a 2900×5367 photo, a 3840×1725 panorama in a subfolder (plus a copy named IMG_0042.JPG to show that uppercase names match), and a 1280×960 PNG with transparency:
$ node convert.mjs photos
convert dice.png → webp 1280×960 485 kB → 119 kB
convert trip/alps.jpg → webp 3840×1725 1573 kB → 1009 kB
convert trip/IMG_0042.JPG → webp 3840×1725 1573 kB → 1009 kB
convert eiffel.jpg → webp 2900×5367 5156 kB → 1582 kB
convert dice.png → avif 1280×960 485 kB → 27 kB
convert trip/alps.jpg → avif 3840×1725 1573 kB → 545 kB
convert trip/IMG_0042.JPG → avif 3840×1725 1573 kB → 545 kB
convert eiffel.jpg → avif 2900×5367 5156 kB → 729 kB
done: 4 images in /w/photosAt the default settings (WebP quality 80, AVIF quality 50), AVIF files came out roughly half the size of the WebP ones, and the PNG went from 485 kB to 27 kB. A second run does nothing, because everything is up to date:
$ node convert.mjs photos
skip trip/alps.jpg → webp (up to date)
skip trip/alps.jpg → avif (up to date)
…
done: 4 images in /w/photosFull-resolution camera images are rarely what you want on a web page. Capping the width shrinks them much further. Here is AVIF only, at most 1600 px wide:
$ time node convert.mjs photos --max-width 1600 --formats avif
convert dice.png → avif 1280×960 485 kB → 27 kB
convert trip/alps.jpg → avif 1600×719 1573 kB → 91 kB
convert trip/IMG_0042.JPG → avif 1600×719 1573 kB → 91 kB
convert eiffel.jpg → avif 1600×2961 5156 kB → 217 kB
done: 4 images in /w/photos
real 0m6.434sThe 5 MB photo is now 217 kB, and the 1280 px PNG kept its size because of withoutEnlargement.
Nothing installed locally? Run the same script in a container. Mount the folder and install sharp inside it:
docker run --rm -v "$PWD":/w -w /w node:24-slim \
sh -c "npm install --silent sharp && node convert.mjs photos"SVG to PNG
sharp also rasterizes SVG (through librsvg), so you don't need Inkscape or a special Docker image for icons and logos. Save as svg-to-png.mjs:
import { glob } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
const dir = path.resolve(process.argv[2] ?? '.');
const width = Number(process.argv[3] ?? 512);
for await (const file of glob('**/*.svg', { cwd: dir })) {
const src = path.join(dir, file);
const out = src.replace(/\.svg$/, `-w${width}.png`);
// Resizing an SVG re-renders the vector at the target size,
// so even a big upscale stays crisp.
const info = await sharp(src).resize({ width }).png().toFile(out);
console.log(`${file} → ${path.basename(out)} ${info.width}×${info.height}`);
}$ node svg-to-png.mjs icons 1024
logo-dark.svg → logo-dark-w1024.png 1024×627
logo.svg → logo-w1024.png 1024×627A lot of advice tells you to pass a high density (DPI) when converting SVGs, or the PNG will be blurry. With .resize() you don't need it: the test logo is 590×361 at its natural size, and rendering it at 1024 px wide with the default 72 DPI and with density: 300 produced PNGs with identical dimensions and an identical file size (42,072 bytes), with crisp edges. density only matters when you don't resize. Then it decides the output size: 72 gives the SVG's natural 590×361, and 144 gives 1180×722.
The FFmpeg one-liners
If FFmpeg is already installed, a shell loop does the basic conversion without any Node.js. These were run with FFmpeg 7.1 from Debian 13 (trixie):
# JPG/PNG → WebP
for i in *.jpg *.png; do ffmpeg -y -i "$i" -c:v libwebp -quality 80 "${i%.*}.webp"; done
# JPG/PNG → AVIF
for i in *.jpg *.png; do ffmpeg -y -i "$i" -c:v libaom-av1 -still-picture 1 -crf 30 "${i%.*}.avif"; done
# Downscale JPGs to at most 1600px wide, into ./small
mkdir -p small
for i in *.jpg; do ffmpeg -y -i "$i" -vf "scale=min(1600\,iw):-2" "small/${i%.*}.jpg"; doneIn the scale filter, min(1600\,iw) never upscales (the comma has to be escaped inside a filter expression), and -2 keeps the aspect ratio while rounding the height to an even number. Many encoders, AV1 included, reject odd dimensions with 4:2:0 chroma.
The gotcha: FFmpeg dropped the transparency. Converting the transparent PNG with the AVIF command above produced a file with no alpha channel: 3 channels, hasAlpha: false. The transparent areas were filled in and FFmpeg printed no warning. The WebP command kept the alpha channel, and so did sharp for both formats. If your folder has logos, icons or UI screenshots with transparency, use sharp for AVIF.
FFmpeg's strength is breadth: GIFs and video frames are just more input to it. For a folder of web images, though, the sharp script gives you recursion, resizing, EXIF rotation, incremental runs and correct transparency in one place.