Public/Convert-ToWebM.ps1
<# .SYNOPSIS Convert-ToWebM - Convert animated content to WebM format for Foundry VTT optimization .DESCRIPTION Converts animated images and videos (GIF, animated WebP, MP4, MOV, MKV, APNG) to WebM format using VP9 or AV1 codecs. Features parallel processing, alpha channel preservation, frame rate limiting, and intelligent scaling for optimal Foundry VTT compatibility and file size reduction. .PARAMETER Root Source directory containing files to convert (default: current directory) .PARAMETER MaxFPS Maximum frame rate for output videos (1-240, default: 30) .PARAMETER MaxWidth Maximum width in pixels, maintains aspect ratio (64-8192, default: 1920) .PARAMETER Codec Video codec: 'vp9' (default) or 'av1' for next-generation compression .PARAMETER MaxBitrateKbps Optional bitrate ceiling in kbps (default: 0 - no limit) .PARAMETER AlphaMode Alpha channel handling: 'auto' (default), 'force', or 'disable' .PARAMETER AlphaBackground Background color when disabling alpha (hex color, e.g., '#000000') .PARAMETER IncludeExt Array of additional extensions to include (e.g., @('.avi', '.wmv')) .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 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-ToWebM Convert all animated content in current directory to WebM .EXAMPLE Convert-ToWebM -Root "D:\FoundryAssets" -OutputRoot "D:\Optimized" -MaxFPS 24 -Parallel Convert all animated content with 24fps limit using parallel processing .EXAMPLE Convert-ToWebM -Root "D:\Maps" -Codec av1 -MaxWidth 1280 -AlphaMode disable -AlphaBackground '#000000' Convert with AV1 codec, scaled to 1280px width, alpha flattened to black background .EXAMPLE Convert-ToWebM -Root "D:\Animations" -Parallel -ThrottleLimit 8 -DeleteSource -LogPath "D:\Logs\conversion.json" High-performance conversion with source cleanup and detailed logging .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 35% of input size) Supported input formats: .gif, .webp, .mp4, .m4v, .mov, .mkv, .apng Performance: 3-4x faster with parallel processing enabled .LINK https://github.com/andresyuhnke/ConvertVTTAssets .LINK https://www.powershellgallery.com/packages/ConvertVTTAssets #> function Convert-ToWebM { [CmdletBinding(SupportsShouldProcess = $true)] param( [string]$Root = ".", [switch]$NoRecurse, [ValidateRange(1,240)][int]$MaxFPS = 30, [ValidateRange(64,8192)][int]$MaxWidth = 1920, [ValidateSet('vp9','av1')][string]$Codec = 'vp9', [int]$MaxBitrateKbps = 0, [ValidateSet('auto','force','disable')][string]$AlphaMode = 'auto', [string]$AlphaBackground, [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 ) # [WEBM-001] Validate required external tools before processing Test-Tool -Name $FfmpegPath Test-Tool -Name $FfprobePath # [WEBM-002] Configure file discovery parameters $recurse = -not $NoRecurse.IsPresent $extensions = @('.gif','.webp','.mp4','.m4v','.mov','.mkv','.apng') # [WEBM-003] Apply extension filtering if specified 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($_) } } # [WEBM-004] Discover candidate 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 files under '$Root'."; return } # [WEBM-005] Initialize progress tracking and user feedback $totalFiles = @($files).Count $fileNum = 0 if (-not $Silent -and $totalFiles -gt 0) { Write-Host "" Write-Host "=== Convert-ToWebM Starting ===" -ForegroundColor Cyan Write-Host "Found $totalFiles file(s) to process" -ForegroundColor Yellow Write-Host "" } # [WEBM-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 { # [WEBM-007] Configure parallel processing settings $settings = @{ Root = $Root; OutputRoot=$OutputRoot; Force=$Force; FfmpegPath=$FfmpegPath; FfprobePath=$FfprobePath; MaxFPS=$MaxFPS; MaxWidth=$MaxWidth; Codec=$Codec; MaxBitrateKbps=$MaxBitrateKbps; AlphaMode=$AlphaMode; AlphaBackground=$AlphaBackground; ThrottleLimit=$ThrottleLimit; WhatIfPreference=$WhatIfPreference; VerbosePreference=$VerbosePreference; Silent=$Silent } # [WEBM-008] Execute parallel conversion using ThreadJob engine $records = Invoke-WebMParallel -Files $files -S $settings } } # [WEBM-009] Sequential processing fallback and PowerShell 5.1 support if ($records.Count -eq 0) { # Sequential engine foreach ($f in $files) { # [WEBM-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 } # [WEBM-011] Initialize conversion result tracking $VerbosePreference = $VerbosePreference $ErrorActionPreference = 'Stop' $result = [ordered]@{ Time = (Get-Date).ToString('s') Command = 'Convert-ToWebM' Source = $f.FullName Destination = $null Status = 'Skipped' Reason = '' DurationSec = 0.0 SrcBytes = $f.Length DstBytes = 0 SizeDeltaBytes = $null SizeDeltaPct = $null Codec = $Codec HasAlpha = $false AlphaMode = $AlphaMode FPSCap = $MaxFPS WidthCap = $MaxWidth } $sw = [System.Diagnostics.Stopwatch]::StartNew() try { # [WEBM-012] Calculate destination path using OutputRoot if specified $dest = Get-DestinationPath -SourceFile $f -Root $Root -OutputRoot $OutputRoot -NewExtension '.webm' $result.Destination = $dest # [WEBM-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 } } # [WEBM-014] Extract media metadata using FFprobe $info = Invoke-FFProbeJson -Path $f.FullName -FfprobePath $FfprobePath $hasAlpha = $false # [WEBM-015] Determine alpha channel presence and handling if ($info) { $hasAlpha = Get-HasAlpha -Info $info } elseif ($f.Extension.ToLower() -in @('.gif','.apng','.webp')) { $hasAlpha = $true } switch ($AlphaMode) { 'force' { $hasAlpha = $true } 'disable' { $hasAlpha = $false } } $result.HasAlpha = $hasAlpha # [WEBM-016] Extract source video properties for processing decisions $srcW = Get-Width -Info $info $srcH = Get-Height -Info $info $srcFps = Get-FrameRate -Info $info # [WEBM-017] Generate FFmpeg filter graph for scaling and frame rate limiting $vf = $null $useFlatten = ($AlphaMode -eq 'disable' -and -not $AlphaBackground) $vf = Get-FilterGraph -SrcWidth $srcW -SrcFps $srcFps -MaxWidth $MaxWidth -MaxFPS $MaxFPS -AlphaMode $AlphaMode -FlattenBlack:$useFlatten # [WEBM-018] Configure codec-specific encoding parameters $codecArgs = @() switch ($Codec) { 'vp9' { $codecArgs = @('-c:v','libvpx-vp9','-crf','28','-b:v','0','-row-mt','1','-threads','0','-speed','2','-deadline','good') if ($hasAlpha) { $codecArgs += @('-pix_fmt','yuva420p','-auto-alt-ref','0') } else { $codecArgs += @('-pix_fmt','yuv420p') } } 'av1' { $codecArgs = @('-c:v','libaom-av1','-crf','30','-b:v','0','-threads','0','-cpu-used','4') if ($hasAlpha) { $codecArgs += @('-pix_fmt','yuva420p') } else { $codecArgs += @('-pix_fmt','yuv420p') } } } # [WEBM-019] Apply bitrate limiting if specified if ($MaxBitrateKbps -gt 0) { $codecArgs += @('-maxrate', ("{0}k" -f $MaxBitrateKbps), '-bufsize', ("{0}k" -f ($MaxBitrateKbps*2))) } # [WEBM-020] Build FFmpeg command line arguments $args = @('-y','-hide_banner','-loglevel','error','-i', $f.FullName) # [WEBM-021] Handle special case: alpha background color replacement $filtersApplied = $false if ($AlphaMode -eq 'disable' -and $AlphaBackground) { $w = $srcW; $h = $srcH if ($w -and $h -and $w -gt 0 -and $h -gt 0) { $bg = ($AlphaBackground).Trim('#') $fc = @() if ($MaxFPS -gt 0) { $fc += ("fps={0}" -f $MaxFPS) } if ($MaxWidth -gt 0 -and $w -gt $MaxWidth) { $fc += ("scale=min(iw\,{0}):-2:flags=lanczos" -f $MaxWidth) } if ($fc.Count -eq 0) { $fc = @('format=rgba') } else { $fc.Insert(0,'format=rgba') } $filterComplex = "color=c=#${bg}:s=${w}x${h}[bg];[0:v]" + ($fc -join ',') + "[v];[bg][v]overlay=format=auto,format=yuv420p" $args += @('-filter_complex', $filterComplex) $filtersApplied = $true } } # [WEBM-022] Apply standard video filters if no complex filtering was used if (-not $filtersApplied) { if ($vf) { $args += @('-filter:v', $vf) } } $args += $codecArgs $args += @('-an','-f','webm', $dest) # [WEBM-023] Handle WhatIf preview mode if ($WhatIfPreference) { Write-Host "WhatIf: would convert '$($f.FullName)' → '$dest'" $result.Status = 'WhatIf' $records += [pscustomobject]$result continue } # [WEBM-024] 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) { # [WEBM-025] 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) } } } # [WEBM-025] 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) } # [WEBM-025.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 # [WEBM-025.2] Write operation log if path specified if ($LogPath) { Write-LogRecords -Records $records -LogPath $LogPath } # [WEBM-025.3] Display final summary to user if (-not $Silent) { Write-Host "" } Write-Host "=== Convert-ToWebM 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) } # [WEBM-026] 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 } } } } } |