Home → Batch convert a folder of images to WebP and AVIF with Node.js (sharp) or FFmpeg

Batch convert a folder of images to WebP and AVIF with Node.js (sharp) or FFmpeg

By · Node.js & JavaScript developer
Published January 25, 2023 · Updated October 1, 2026

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 sharp

You 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, *.jpg does not match IMG_0042.JPG, the name a phone camera gives a photo. That's why the pattern lists both spellings.
  • autoOrient: true rotates the pixels according to the EXIF Orientation tag. sharp strips metadata from the output by default, so without this a portrait phone photo can come out sideways.
  • withoutEnlargement makes --max-width a 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/photos

At 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/photos

Full-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.434s

The 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×627

A 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"; done

In 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.

Sources & further reading

About Code with Node.js

This is a personal blog and reference point of a Node.js developer.

I write and explain how different Node and JavaScript aspects work, as well as research popular and cool packages, and of course fail time to time.