<# .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 } }