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.
- 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.
<#
.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
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-Path | string[] | . | Image files, or folders to search. Takes pipeline input. |
-Recurse | switch | — | Search subfolders too. |
-Include | string[] | .jpg, .jpeg, .png | Which extensions to convert. .tif and .tiff are allowed as well. |
-OutputPath | string | next to each original | A separate folder for the WebP files. With -Recurse, the subfolder structure is recreated underneath it. |
-Quality | int | 85 | WebP quality, 1 to 100. 75 to 85 suits photos. With -Lossless it controls how hard cwebp works instead. |
-Lossless | switch | — | Lossless WebP, cwebp only. Great for screenshots and flat graphics, pointless for photos. |
-KeepMetadata | switch | — | Tells cwebp to copy EXIF, ICC and XMP data across, which it drops by default. No effect with NConvert. |
-Converter | string | Auto | Auto tries cwebp, then NConvert. Or name one. |
-ConverterPath | string | — | Full path to cwebp.exe or nconvert.exe if it isn't on your PATH. |
-RemoveOriginal | switch | — | Delete each original once its WebP is written and smaller. Honors -WhatIf. |
-Force | switch | — | 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 -WhatIfConvert a website's images into a separate folder at a lower quality.
.\Convert-ImageToWebP.ps1 -Path .\site\images -Recurse -OutputPath .\site\images-webp -Quality 78Shrink a screenshots folder losslessly and clear out the PNGs.
.\Convert-ImageToWebP.ps1 -Path .\Screenshots -Include .png -Lossless -RemoveOriginalUse 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, SumWhat you'll see
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
- Find a converter.
-ConverterPathwins if you give it. Otherwise it looks forcwebpon your PATH, thennconvert. If neither is there, it throws before doing anything, with the download links for both. - 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. - Convert to a temporary name. cwebp gets
-quiet -mt -q <quality>(plus-losslessor-metadata allif 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.webpsitting where a good one should be. - Keep the date and measure. The new file gets the original's
LastWriteTime, so sorting by date in Explorer still works. Then it works outAfterKBandSavedPct. - Remove the original only when it's a win. With
-RemoveOriginal, the source is deleted throughShouldProcess, 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 0caps 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
.jpgand.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.