Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

SCRIPT LIBRARY · POWERSHELL

Batch-Convert a Folder of JPG and PNG Images to WebP

Shrink a folder of photos or screenshots to WebP with cwebp or NConvert, keep the originals unless you say otherwise, and see how much each file saved.

AT A GLANCEConvert-ImageToWebP.ps1
What it does
Finds JPG and PNG images in the folders or files you give it, converts each to WebP with cwebp or NConvert, copies the original's modified date across, and reports the size before and after. Originals are only deleted with -RemoveOriginal, and never when the WebP came out bigger.
Requires
  • Windows PowerShell 5.1 or PowerShell 7+
  • cwebp (from Google's libwebp tools) or NConvert (from XnView)
Permissions
Read and write access to the image folders. No admin rights.
Runs on
Windows 10/11 (and PowerShell 7 on macOS or Linux)
Tested
Parse-checked and run in PowerShell 7.4 with -WhatIf and for real, using stand-in cwebp and NConvert wrappers around ImageMagick (the real tools weren't installed on the test machine), including the missing-converter error, a corrupt JPEG, a name clash, a re-run over converted files, -OutputPath with -Recurse, and -RemoveOriginal

Part 7 of the thread File wrangling

WebP files usually come out a good deal smaller than the JPEGs and PNGs they replace, and just about everything reads them now: browsers, image viewers, most photo apps. When a folder of screenshots or a website's images folder is getting heavy, converting it is an easy win.

My original script converted exactly one image, needed NConvert on the PATH, and printed a tidy little size comparison. Fine for one file. For a folder I wanted a batch: find the images, convert them, keep their dates, and tell me what I saved.

This version works with either of the two command-line converters worth having: Google's cwebp, which it prefers, or XnView's NConvert. If it can't find either, it stops before touching anything and tells you where to get them. Originals stay put unless you pass -RemoveOriginal, and even then a WebP that came out bigger never replaces its source.

Convert-ImageToWebP.ps1Download
<#
.SYNOPSIS
    Batch-converts JPG and PNG images to WebP with cwebp or NConvert, keeping the originals unless you say otherwise.
.DESCRIPTION
    Finds images in the folders or files you give it, converts each one to WebP with Google's cwebp
    (preferred) or XnView's NConvert, copies the original's modified date onto the new file, and
    reports the size before and after. Existing .webp files are skipped unless -Force. Originals
    are only deleted with -RemoveOriginal, and never when the WebP came out bigger.
    Every conversion and deletion goes through ShouldProcess, so -WhatIf works.
.PARAMETER Path
    Image files, or folders to search. Accepts pipeline input. Default: the current folder.
.PARAMETER Recurse
    Search subfolders too.
.PARAMETER Include
    Which extensions to convert. Default: .jpg, .jpeg, .png.
.PARAMETER OutputPath
    Folder for the WebP files. Default: next to each original. With -Recurse, subfolders are recreated underneath it.
.PARAMETER Quality
    WebP quality, 1-100. Default: 85. With -Lossless it sets how hard cwebp works instead.
.PARAMETER Lossless
    Lossless WebP (cwebp only). Good for screenshots and flat graphics, pointless for photos.
.PARAMETER KeepMetadata
    Ask cwebp to copy EXIF, ICC and XMP data. cwebp drops it by default. No effect with NConvert.
.PARAMETER Converter
    Auto (default) uses cwebp if it can find it, then NConvert.
.PARAMETER ConverterPath
    Full path to cwebp.exe or nconvert.exe if it isn't on PATH.
.PARAMETER RemoveOriginal
    Delete each original once its WebP is written and smaller.
.PARAMETER Force
    Overwrite WebP files that already exist.
.EXAMPLE
    .\Convert-ImageToWebP.ps1 -Path D:\Photos\2026 -Recurse -WhatIf
.EXAMPLE
    .\Convert-ImageToWebP.ps1 -Path .\screenshots -Include .png -Lossless -RemoveOriginal
#>
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
    [Alias('FullName')]
    [string[]]$Path = '.',
    [switch]$Recurse,
    [ValidateSet('.jpg', '.jpeg', '.png', '.tif', '.tiff')][string[]]$Include = @('.jpg', '.jpeg', '.png'),
    [string]$OutputPath,
    [ValidateRange(1, 100)][int]$Quality = 85,
    [switch]$Lossless,
    [switch]$KeepMetadata,
    [ValidateSet('Auto', 'cwebp', 'nconvert')][string]$Converter = 'Auto',
    [string]$ConverterPath,
    [switch]$RemoveOriginal,
    [switch]$Force
)

begin {
    function Find-Tool([string]$Name) {
        Get-Command -Name $Name, "$Name.exe" -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
    }

    if ($ConverterPath) {
        if (-not (Test-Path -LiteralPath $ConverterPath -PathType Leaf)) { throw "ConverterPath '$ConverterPath' doesn't exist." }
        if ($Converter -eq 'Auto') { $Converter = if ((Split-Path $ConverterPath -Leaf) -match 'nconvert') { 'nconvert' } else { 'cwebp' } }
    }
    else {
        $candidates = if ($Converter -eq 'Auto') { 'cwebp', 'nconvert' } else { , $Converter }
        foreach ($name in $candidates) {
            $found = Find-Tool $name
            if ($found) { $ConverterPath = $found; $Converter = $name; break }
        }
        if (-not $ConverterPath) {
            $wanted = if ($candidates.Count -gt 1) { 'Neither cwebp nor NConvert was found' } else { "$candidates wasn't found" }
            throw "$wanted. Install cwebp (part of Google's libwebp tools, https://developers.google.com/speed/webp/download) or NConvert (https://www.xnview.com/en/nconvert/), put it on your PATH, or pass -ConverterPath."
        }
    }
    if ($Lossless -and $Converter -eq 'nconvert') { throw '-Lossless needs cwebp. Drop -Lossless or point -ConverterPath at cwebp.' }
    Write-Verbose "Using $Converter at $ConverterPath"

    if ($OutputPath -and -not (Test-Path -LiteralPath $OutputPath -PathType Container) -and -not $WhatIfPreference) {
        $null = New-Item -ItemType Directory -Path $OutputPath -Force
    }

    # Two sources with the same base name (photo.jpg and photo.png) would write the same .webp. First one wins.
    $claimed = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)

    $display = [System.Management.Automation.PSPropertySet]::new('DefaultDisplayPropertySet', [string[]]@('Name', 'AfterKB', 'SavedPct', 'Status'))
    $standardMembers = [System.Management.Automation.PSMemberInfo[]]@($display)
}

process {
    $jobs = foreach ($item in $Path) {
        try { $resolved = Get-Item -LiteralPath $item -ErrorAction Stop }
        catch { Write-Warning "Can't find '$item'."; continue }
        if ($resolved.PSIsContainer) {
            Get-ChildItem -LiteralPath $resolved.FullName -File -Recurse:$Recurse |
                Where-Object { $Include -contains $_.Extension.ToLower() } |
                ForEach-Object { [pscustomobject]@{ File = $_; Base = $resolved.FullName } }
        }
        elseif ($Include -contains $resolved.Extension.ToLower()) { [pscustomobject]@{ File = $resolved; Base = $resolved.DirectoryName } }
        else { Write-Warning "$($resolved.Name) isn't one of $($Include -join ', '), skipped." }
    }

    foreach ($job in $jobs) {
        $image = $job.File
        $destDir = $image.DirectoryName
        if ($OutputPath) {
            $relative = $image.DirectoryName.Substring($job.Base.Length).TrimStart('\', '/')
            $destDir = if ($relative) { Join-Path $OutputPath $relative } else { $OutputPath }
        }
        $target = Join-Path $destDir ($image.BaseName + '.webp')
        $result = [ordered]@{ Name = $image.Name; BeforeKB = [math]::Round($image.Length / 1KB); AfterKB = $null; SavedPct = $null; Status = $null; Output = $target }

        try {
            if (-not $claimed.Add($target)) {
                $result.Status = 'SkippedNameClash'
                Write-Warning "$($image.Name): another image in this run already writes $(Split-Path $target -Leaf). Rename one, or convert it on its own with -OutputPath."
            }
            elseif ((Test-Path -LiteralPath $target) -and -not $Force) { $result.Status = 'SkippedExists' }
            elseif (-not $PSCmdlet.ShouldProcess($image.FullName, "Convert to $target")) { $result.Status = 'WouldConvert' }
            else {
                if (-not (Test-Path -LiteralPath $destDir)) { $null = New-Item -ItemType Directory -Path $destDir -Force -ErrorAction Stop }
                # Write to a temp name first, so a failed run never leaves a half-written .webp behind.
                $temp = Join-Path $destDir ($image.BaseName + '.' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.tmp.webp')
                try {
                    if ($Converter -eq 'cwebp') {
                        $cwebpArgs = @('-quiet', '-mt', '-q', $Quality)
                        if ($Lossless) { $cwebpArgs += '-lossless' }
                        if ($KeepMetadata) { $cwebpArgs += '-metadata', 'all' }
                        $out = & $ConverterPath @cwebpArgs $image.FullName -o $temp 2>&1
                    }
                    else {
                        $out = & $ConverterPath -quiet -out webp -q $Quality -o $temp $image.FullName 2>&1
                    }
                    if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $temp) -or (Get-Item -LiteralPath $temp -Force).Length -eq 0) {
                        throw "$Converter failed (exit code $LASTEXITCODE): $((@($out) | Select-Object -Last 2) -join ' ')"
                    }
                    Move-Item -LiteralPath $temp -Destination $target -Force -ErrorAction Stop -WhatIf:$false
                }
                finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue -WhatIf:$false }

                $webp = Get-Item -LiteralPath $target -Force
                $webp.LastWriteTime = $image.LastWriteTime
                $result.AfterKB = [math]::Round($webp.Length / 1KB)
                $result.SavedPct = if ($image.Length) { [math]::Round((1 - $webp.Length / $image.Length) * 100, 1) } else { 0 }

                if ($webp.Length -ge $image.Length) {
                    $result.Status = 'ConvertedLarger'
                    Write-Warning "$($image.Name): the WebP is bigger than the original, so the original stays."
                }
                elseif ($RemoveOriginal -and $PSCmdlet.ShouldProcess($image.FullName, 'Delete original')) {
                    Remove-Item -LiteralPath $image.FullName -Force -ErrorAction Stop
                    $result.Status = 'ConvertedRemovedOriginal'
                }
                else { $result.Status = 'Converted' }
            }
        }
        catch {
            $result.Status = "Failed: $($_.Exception.Message)"
            Write-Warning "$($image.Name): $($_.Exception.Message)"
        }
        [pscustomobject]$result | Add-Member -MemberType MemberSet -Name PSStandardMembers -Value $standardMembers -PassThru
    }
}

Parameters

ParameterTypeDefaultWhat it's for
-Pathstring[].Image files, or folders to search. Takes pipeline input.
-Recurseswitch—Search subfolders too.
-Includestring[].jpg, .jpeg, .pngWhich extensions to convert. .tif and .tiff are allowed as well.
-OutputPathstringnext to each originalA separate folder for the WebP files. With -Recurse, the subfolder structure is recreated underneath it.
-Qualityint85WebP quality, 1 to 100. 75 to 85 suits photos. With -Lossless it controls how hard cwebp works instead.
-Losslessswitch—Lossless WebP, cwebp only. Great for screenshots and flat graphics, pointless for photos.
-KeepMetadataswitch—Tells cwebp to copy EXIF, ICC and XMP data across, which it drops by default. No effect with NConvert.
-ConverterstringAutoAuto tries cwebp, then NConvert. Or name one.
-ConverterPathstring—Full path to cwebp.exe or nconvert.exe if it isn't on your PATH.
-RemoveOriginalswitch—Delete each original once its WebP is written and smaller. Honors -WhatIf.
-Forceswitch—Overwrite WebP files that already exist. Without it, they're skipped, so re-running is cheap.

Run it

See what would be converted, without writing anything.

.\Convert-ImageToWebP.ps1 -Path D:\Photos\2026 -Recurse -WhatIf

Convert a website's images into a separate folder at a lower quality.

.\Convert-ImageToWebP.ps1 -Path .\site\images -Recurse -OutputPath .\site\images-webp -Quality 78

Shrink a screenshots folder losslessly and clear out the PNGs.

.\Convert-ImageToWebP.ps1 -Path .\Screenshots -Include .png -Lossless -RemoveOriginal

Use a portable copy of NConvert.

.\Convert-ImageToWebP.ps1 -Path D:\Scans -ConverterPath 'C:\Tools\NConvert\nconvert.exe'

How much space the whole run saved, in KB.

$r = .\Convert-ImageToWebP.ps1 -Path D:\Photos -Recurse; $r | Where-Object AfterKB | Measure-Object BeforeKB, AfterKB -Sum | Select-Object Property, Sum

What you'll see

Example outputvalues are illustrative
WARNING: IMG_4488.jpg: cwebp failed (exit code 255): Could not process file D:\Photos\2026\IMG_4488.jpg
WARNING: icon-16.png: the WebP is bigger than the original, so the original stays.

Name             AfterKB SavedPct Status
----             ------- -------- ------
IMG_4471.jpg         612     71.4 Converted
IMG_4472.jpg         588     72.9 Converted
IMG_4488.jpg                      Failed: cwebp failed (exit code 255): Could not process file ...
contoso-map.png      143     88.2 Converted
beach.png                         SkippedNameClash
icon-16.png            1    -12.5 ConvertedLarger
team-photo.jpg                    SkippedExists

How it works

  1. Find a converter. -ConverterPath wins if you give it. Otherwise it looks for cwebp on your PATH, then nconvert. If neither is there, it throws before doing anything, with the download links for both.
  2. Collect the images. Folders are searched for the extensions in -Include, with or without -Recurse. Files you name directly are converted as long as they're one of those types. With -OutputPath, each image's subfolder is recreated under the output folder, so a nested photo library stays nested.
  3. Convert to a temporary name. cwebp gets -quiet -mt -q <quality> (plus -lossless or -metadata all if you asked), NConvert gets -quiet -out webp -q <quality>. The result is checked for a zero exit code and a non-empty file, then moved into place, so a crash halfway never leaves a broken .webp sitting where a good one should be.
  4. Keep the date and measure. The new file gets the original's LastWriteTime, so sorting by date in Explorer still works. Then it works out AfterKB and SavedPct.
  5. Remove the original only when it's a win. With -RemoveOriginal, the source is deleted through ShouldProcess, and only when the WebP is actually smaller.

Take it further

  • Comics are a separate job. Pages inside CBR and CBZ archives need extracting and repacking, which the CBR to CBZ converter already does, WebP pages included.
  • Resize while you're at it. Most of the savings on phone photos come from resolution, not quality. cwebp's -resize 2048 0 caps the width and keeps the aspect ratio; add it to the argument list in the script.
  • Update the references. If the images live on a website, remember the HTML and Markdown still point at .jpg and .png. A quick find-and-replace over the site's source handles it, or serve both with a <picture> element.

Things that'll trip you up

  • cwebp drops EXIF unless you ask. Out of the box, cwebp writes no metadata, so the date taken, camera details and GPS are gone. Photo apps that sort by date taken will get confused. Use -KeepMetadata for photos. The script copies the file's modified date across either way.
  • Tiny images can get bigger. Small icons and simple PNGs are sometimes already smaller than a WebP can be. Those come back as ConvertedLarger with a negative SavedPct, and the original is never removed, even with -RemoveOriginal.
  • Same name, different extension. photo.jpg and photo.png in the same folder would both become photo.webp. The first one wins, and the second is reported as SkippedNameClash so nothing is quietly overwritten. Rename one, or convert it separately with -OutputPath.
  • Lossy is lossy. Converting a JPEG to lossy WebP compresses something that was already compressed. At 80 or so you won't see it, but don't point -RemoveOriginal at the only copy of your family photos. Convert, look at a few, then decide.
  • -Lossless needs cwebp. The script refuses to start with -Lossless and NConvert rather than quietly producing lossy files. Install cwebp, or drop the switch.