Public/Convert-ToWebP.ps1
<# .SYNOPSIS Convert-ToWebP - Convert static images to WebP format for Foundry VTT optimization .DESCRIPTION Converts static images (PNG, JPG/JPEG, TIFF, BMP) to WebP format with configurable quality settings. Features parallel processing, intelligent scaling, lossless/lossy compression options, and significant file size reduction while maintaining visual quality for optimal Foundry VTT performance. .PARAMETER Root Source directory containing images to convert (default: current directory) .PARAMETER Quality Compression quality for lossy WebP (1-100, default: 80, higher = better quality) .PARAMETER MaxWidth Maximum width in pixels, maintains aspect ratio (64-8192, default: 4096) .PARAMETER IncludeExt Array of additional extensions to include (e.g., @('.tga', '.dds')) .PARAMETER ExcludeExt Array of extensions to skip processing .PARAMETER ThrottleLimit Maximum concurrent conversions (1-64, default: 4) .PARAMETER FfmpegPath Path to FFmpeg executable (default: 'ffmpeg') .PARAMETER FfprobePath Path to FFprobe executable (default: 'ffprobe') .PARAMETER OutputRoot Destination directory for converted files (preserves directory structure) .PARAMETER LogPath Path for operation log file (.csv or .json format) .PARAMETER NoRecurse Only process files in root directory, skip subdirectories .PARAMETER Lossless Enable lossless WebP compression (ignores Quality setting) .PARAMETER Parallel Enable parallel processing using ThreadJob engine (PowerShell 7+ only) .PARAMETER Force Re-convert files even if destination already exists and is newer .PARAMETER DeleteSource Send original files to Recycle Bin after successful conversion .PARAMETER Silent Suppress progress output and display minimal information .PARAMETER WhatIf Preview what would be converted without making any changes .EXAMPLE Convert-ToWebP Convert all static images in current directory to WebP with default quality .EXAMPLE Convert-ToWebP -Root "D:\Portraits" -OutputRoot "D:\Optimized" -Quality 90 -Parallel Convert portraits with high quality using parallel processing .EXAMPLE Convert-ToWebP -Root "D:\Tokens" -Lossless -MaxWidth 512 Convert tokens with lossless compression, scaled to 512px maximum width .EXAMPLE Convert-ToWebP -Root "D:\Maps" -Quality 75 -DeleteSource -LogPath "D:\Logs\webp_conversion.csv" Convert maps, delete originals, and log all operations to CSV file .NOTES Author: Andres Yuhnke, Claude (Anthropic) Version: 1.6.0 Requirements: - FFmpeg and FFprobe in PATH or specified via -FfmpegPath/-FfprobePath - PowerShell 7+ recommended for parallel processing - Sufficient disk space (output typically 25-40% of input size) Supported input formats: .png, .jpg, .jpeg, .tif, .tiff, .bmp Performance: 3-4x faster with parallel processing enabled Quality recommendations: 85-95 for portraits, 75-85 for maps, 90+ for UI elements .LINK https://github.com/andresyuhnke/ConvertVTTAssets .LINK https://www.powershellgallery.com/packages/ConvertVTTAssets #> function Convert-ToWebP { [CmdletBinding(SupportsShouldProcess = $true)] param( [string]$Root = ".", [switch]$NoRecurse, [ValidateRange(1,100)][int]$Quality = 80, [switch]$Lossless, [ValidateRange(64,8192)][int]$MaxWidth = 4096, [string[]]$IncludeExt, [string[]]$ExcludeExt, [switch]$Parallel, [ValidateRange(1,64)][int]$ThrottleLimit = 4, [string]$FfmpegPath = 'ffmpeg', [string]$FfprobePath = 'ffprobe', [switch]$Force, [switch]$DeleteSource, [string]$OutputRoot, [string]$LogPath, [switch]$Silent ) # [WEBP-001] Validate required external tools before processing Test-Tool -Name $FfmpegPath Test-Tool -Name $FfprobePath # [WEBP-002] Configure file discovery parameters for static image formats $recurse = -not $NoRecurse.IsPresent $extensions = @('.png','.jpg','.jpeg','.tif','.tiff','.bmp') # [WEBP-003] Apply extension filtering if specified by user if ($IncludeExt) { $extensions += ($IncludeExt | ForEach-Object { $_.ToLower() }) $extensions = $extensions | Select-Object -Unique } if ($ExcludeExt) { $excludeSet = [System.Collections.Generic.HashSet[string]]::new([string[]]($ExcludeExt | ForEach-Object { $_.ToLower() })) $extensions = $extensions | Where-Object { -not $excludeSet.Contains($_) } } # [WEBP-004] Discover candidate static image files for conversion $files = Get-ChildItem -LiteralPath $Root -File -Recurse:$recurse | Where-Object { $extensions -contains ([System.IO.Path]::GetExtension($_.Name).ToLower()) } | Sort-Object FullName if (-not $files) { Write-Verbose "No candidate static images under '$Root'."; return } # [WEBP-005] Initialize progress tracking and user feedback $totalFiles = @($files).Count $fileNum = 0 if (-not $Silent -and $totalFiles -gt 0) { Write-Host "" Write-Host "=== Convert-ToWebP Starting ===" -ForegroundColor Cyan Write-Host "Found $totalFiles file(s) to process" -ForegroundColor Yellow Write-Host "" } # [WEBP-006] Choose processing engine based on PowerShell version and user preference $records = @() if ($Parallel) { if (-not $script:IsPS7) { Write-Warning "-Parallel requested but you're on Windows PowerShell 5.1. Falling back to sequential. For real parallelism, run in PowerShell 7+ (pwsh)." } else { # [WEBP-007] Configure parallel processing settings for WebP conversion $settings = @{ Root=$Root; OutputRoot=$OutputRoot; Force=$Force; FfmpegPath=$FfmpegPath; FfprobePath=$FfprobePath; Quality=$Quality; Lossless=$Lossless; MaxWidth=$MaxWidth; ThrottleLimit=$ThrottleLimit; WhatIfPreference=$WhatIfPreference; VerbosePreference=$VerbosePreference; Silent=$Silent } # [WEBP-008] Execute parallel conversion using ThreadJob engine $records = Invoke-WebPParallel -Files $files -S $settings } } # [WEBP-009] Sequential processing fallback and PowerShell 5.1 support if ($records.Count -eq 0) { # Sequential engine foreach ($f in $files) { # [WEBP-010] Display conversion progress to user if (-not $Silent) { $fileNum++ $percentComplete = [math]::Round(($fileNum / $totalFiles) * 100, 0) $fileName = Split-Path $f.Name -Leaf Write-Host ("[{0,3}%] Processing {1}/{2}: {3}" -f $percentComplete, $fileNum, $totalFiles, $fileName) -ForegroundColor Cyan } # [WEBP-011] Initialize conversion result tracking $VerbosePreference = $VerbosePreference $ErrorActionPreference = 'Stop' $result = [ordered]@{ Time = (Get-Date).ToString('s') Command = 'Convert-ToWebP' Source = $f.FullName Destination = $null Status = 'Skipped' Reason = '' DurationSec = 0.0 SrcBytes = $f.Length DstBytes = 0 SizeDeltaBytes = $null SizeDeltaPct = $null Quality = $Quality Lossless = [bool]$Lossless WidthCap = $MaxWidth } $sw = [System.Diagnostics.Stopwatch]::StartNew() try { # [WEBP-012] Calculate destination path using OutputRoot if specified $dest = Get-DestinationPath -SourceFile $f -Root $Root -OutputRoot $OutputRoot -NewExtension '.webp' $result.Destination = $dest # [WEBP-013] Check if conversion can be skipped (up-to-date destination exists) if (-not $Force -and (Test-Path $dest)) { $dstInfo = Get-Item $dest if ($dstInfo.LastWriteTimeUtc -ge $f.LastWriteTimeUtc) { $result.Status = 'Skipped'; $result.Reason='UpToDate' if (-not $Silent) { Write-Host " ⚠ Skipped: Already up-to-date" -ForegroundColor Yellow } $records += [pscustomobject]$result continue } } # [WEBP-014] Extract image metadata using FFprobe $info = Invoke-FFProbeJson -Path $f.FullName -FfprobePath $FfprobePath $srcW = Get-Width -Info $info # [WEBP-015] Generate scaling filter if image exceeds maximum width $vf = $null if ($srcW -and $srcW -gt $MaxWidth) { $vf = "scale=min(iw\,${MaxWidth}):-2:flags=lanczos" } # [WEBP-016] Build FFmpeg command line arguments for WebP conversion $args = @('-y','-hide_banner','-loglevel','error','-i', $f.FullName) if ($vf) { $args += @('-vf', $vf) } $args += @('-c:v','libwebp') # [WEBP-017] Configure compression settings based on lossless/lossy mode if ($Lossless) { $args += @('-lossless','1','-compression_level','6') } else { $args += @('-q:v', $Quality) } $args += @('-frames:v','1', $dest) # [WEBP-018] Handle WhatIf preview mode if ($WhatIfPreference) { Write-Host "WhatIf: would convert '$($f.FullName)' → '$dest'" $result.Status = 'WhatIf' $records += [pscustomobject]$result continue } # [WEBP-019] Execute FFmpeg conversion and evaluate success & $FfmpegPath @args $ok = ($LASTEXITCODE -eq 0) if ($ok) { $result.Status = 'Converted' $dst = Get-Item $dest -ErrorAction SilentlyContinue if ($dst) { # [WEBP-020] Calculate size reduction metrics $result.DstBytes = $dst.Length if ($result.SrcBytes -gt 0) { $result.SizeDeltaBytes = [long]($result.DstBytes - $result.SrcBytes) $result.SizeDeltaPct = [math]::Round((($result.DstBytes / [double]$result.SrcBytes) - 1.0) * 100.0, 2) } } if (-not $Silent) { $reduction = if ($result.SizeDeltaPct) { "{0:N1}%" -f $result.SizeDeltaPct } else { "N/A" } Write-Host " ✓ Converted: $(Split-Path $dest -Leaf) (Size reduction: $reduction)" -ForegroundColor Green } } else { $result.Status = 'Failed'; $result.Reason = "ffmpeg exit $LASTEXITCODE" if (-not $Silent) { Write-Host " ✗ Failed: $($result.Reason)" -ForegroundColor Red } } $records += [pscustomobject]$result } catch { $result.Status = 'Failed'; $result.Reason = $_.Exception.Message if (-not $Silent) { Write-Host " ✗ Failed: $($result.Reason)" -ForegroundColor Red } $records += [pscustomobject]$result } finally { $sw.Stop(); $result.DurationSec = [math]::Round($sw.Elapsed.TotalSeconds,2) } } } # [WEBP-021] Generate comprehensive operation summary $conv = $records | Where-Object {$_.Status -eq 'Converted'} $srcTotal = ($conv | Measure-Object -Property SrcBytes -Sum).Sum $dstTotal = ($conv | Measure-Object -Property DstBytes -Sum).Sum $delta = $dstTotal - $srcTotal $pct = $null if ($srcTotal -gt 0) { $pct = [math]::Round((($dstTotal / [double]$srcTotal) - 1.0) * 100.0, 2) } # [WEBP-021.1] Calculate summary statistics for display $converted = $conv.Count $skipped = ($records | Where-Object {$_.Status -eq 'Skipped'}).Count $failed = ($records | Where-Object {$_.Status -eq 'Failed'}).Count $whatif = ($records | Where-Object {$_.Status -eq 'WhatIf'}).Count # [WEBP-021.2] Write operation log if path specified if ($LogPath) { Write-LogRecords -Records $records -LogPath $LogPath } # [WEBP-021.3] Display final summary to user if (-not $Silent) { Write-Host "" } Write-Host "=== Convert-ToWebP Summary ===" Write-Host ("Converted: {0}" -f $converted) Write-Host ("Skipped: {0}" -f $skipped) if ($whatif -gt 0) { Write-Host ("WhatIf: {0}" -f $whatif) } Write-Host ("Failed: {0}" -f $failed) if ($converted -gt 0) { Write-Host ("Size Total → Src: {0} Dst: {1} Δ: {2} ({3}%)" -f (Format-Bytes $srcTotal),(Format-Bytes $dstTotal),(Format-Bytes $delta),$pct) } # [WEBP-022] Handle source file cleanup if requested if ($DeleteSource.IsPresent -and -not $WhatIfPreference) { foreach ($rec in $conv) { if (Test-Path $rec.Destination) { $dstInfo = Get-Item $rec.Destination if ($dstInfo.Length -gt 0 -and (Test-Path $rec.Source)) { $null = Move-ToRecycleBin -Path $rec.Source -WhatIf:$WhatIfPreference } } } } } |