ConvertVTTAssets.psm1
# ConvertVTTAssets.psm1 (v1.5.1) # Public functions: # Convert-ToWebM (animated -> .webm) # Convert-ToWebP (static -> .webp) #region ===== Version-gated parallel helpers ===== $script:IsPS7 = $PSVersionTable.PSVersion.Major -ge 7 if ($script:IsPS7) { . $PSScriptRoot\ConvertVTTAssets.Core.ps1 } #endregion #region ===== Shared Helpers ===== function Test-Tool { param([Parameter(Mandatory=$true)][string]$Name) $ErrorActionPreferenceBackup = $ErrorActionPreference $ErrorActionPreference = 'SilentlyContinue' $null = & $Name -version 2>$null $ErrorActionPreference = $ErrorActionPreferenceBackup if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne $null) { throw "Required tool '$Name' not found or not executable. Add it to PATH or pass a full path." } } function Invoke-FFProbeJson { [CmdletBinding()] param( [Parameter(Mandatory=$true)][string]$Path, [Parameter(Mandatory=$true)][string]$FfprobePath ) $args = @('-v','error','-print_format','json','-select_streams','v:0','-show_streams','-show_format',$Path) $json = & $FfprobePath @args 2>$null if (-not $json) { return $null } try { return $json | ConvertFrom-Json } catch { return $null } } function Get-HasAlpha { param($Info) if (-not $Info) { return $false } $fmt = $Info.streams[0].pix_fmt if (-not $fmt) { return $false } return ($fmt -match 'a') -or ($fmt -match 'rgba|bgra|argb|abgr|ya8|yuva420p|yuva422p|yuva444p') } function Get-FrameRate { param($Info) if (-not $Info) { return $null } $r = $Info.streams[0].avg_frame_rate if ([string]::IsNullOrWhiteSpace($r) -or $r -eq '0/0') { $r = $Info.streams[0].r_frame_rate } if (-not $r -or $r -eq '0/0') { return $null } if ($r -match '^\d+/\d+$') { $num,$den = $r -split '/' if ([int]$den -ne 0) { return [double]$num / [double]$den } else { return $null } } [double]::TryParse($r, [ref]([double]$fr = 0)) | Out-Null if ($fr -gt 0) { return $fr } else { return $null } } function Get-Width { param($Info) if (-not $Info) { return $null } return $Info.streams[0].width } function Get-Height{ param($Info) if (-not $Info) { return $null } return $Info.streams[0].height } function Get-FilterGraph { param( [int]$SrcWidth, [double]$SrcFps, [int]$MaxWidth, [int]$MaxFPS, [string]$AlphaMode = 'auto', [switch]$FlattenBlack ) $filters = @() if ($SrcWidth -and $SrcWidth -gt $MaxWidth) { $filters += "scale=min(iw\,${MaxWidth}):-2:flags=lanczos" } if ($SrcFps -and ($SrcFps -gt $MaxFPS)) { $filters += "fps=${MaxFPS}" } if ($AlphaMode -eq 'disable' -and $FlattenBlack) { $filters += "format=yuv420p" } if ($filters.Count -gt 0) { return ($filters -join ',') } return $null } function Get-DestinationPath { [CmdletBinding()] param( [Parameter(Mandatory=$true)][System.IO.FileInfo]$SourceFile, [Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$false)][string]$OutputRoot, [Parameter(Mandatory=$true)][string]$NewExtension ) if ([string]::IsNullOrWhiteSpace($OutputRoot)) { $destDir = $SourceFile.DirectoryName } else { $rootFull = (Resolve-Path -LiteralPath $Root).Path.TrimEnd('\') $srcDirFull = (Resolve-Path -LiteralPath $SourceFile.DirectoryName).Path.TrimEnd('\') $rootUri = New-Object System.Uri(($rootFull + '\')) $dirUri = New-Object System.Uri(($srcDirFull + '\')) $relUri = $rootUri.MakeRelativeUri($dirUri).ToString() $relPath = [System.Uri]::UnescapeDataString($relUri) -replace '/', '\' if ([string]::IsNullOrWhiteSpace($relPath)) { $destDir = $OutputRoot } else { $destDir = Join-Path $OutputRoot $relPath } if (-not (Test-Path -LiteralPath $destDir)) { if ($PSCmdlet.ShouldProcess($destDir, "Create destination directory")) { New-Item -ItemType Directory -Force -Path $destDir | Out-Null } else { Write-Host "WhatIf: would create directory '$destDir'" } } } $destName = [System.IO.Path]::GetFileNameWithoutExtension($SourceFile.Name) + $NewExtension return (Join-Path $destDir $destName) } function Move-ToRecycleBin { [CmdletBinding(SupportsShouldProcess=$true)] param([Parameter(Mandatory=$true)][string]$Path) try { Add-Type -AssemblyName Microsoft.VisualBasic -ErrorAction SilentlyContinue } catch { Write-Verbose "Add-Type already loaded or not available: $_" } if ($PSCmdlet.ShouldProcess($Path, "Send to Recycle Bin")) { try { [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile( $Path, [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs, [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin ) Write-Verbose "Sent to Recycle Bin: $Path" return $true } catch { Write-Warning "Failed to recycle '$Path': $($_.Exception.Message)" return $false } } else { Write-Host "WhatIf: would send to Recycle Bin '$Path'" return $true } } function Write-LogRecords { param( [Parameter(Mandatory=$true)][System.Collections.IEnumerable]$Records, [Parameter(Mandatory=$true)][string]$LogPath ) $dir = [System.IO.Path]::GetDirectoryName($LogPath) if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } $ext = [System.IO.Path]::GetExtension($LogPath).ToLower() switch ($ext) { '.csv' { $Records | Export-Csv -NoTypeInformation -Path $LogPath -Encoding UTF8 } '.json' { $Records | ConvertTo-Json -Depth 5 | Set-Content -Path $LogPath -Encoding UTF8 } default { $Records | Export-Csv -NoTypeInformation -Path $LogPath -Encoding UTF8 } } } function Format-Bytes { param([long]$Bytes) if ($Bytes -eq $null) { return '' } $sizes = 'B','KB','MB','GB','TB' $i = 0; $val = [double]$Bytes while ($val -ge 1024 -and $i -lt $sizes.Length-1) { $val /= 1024; $i++ } return ('{0:N2} {1}' -f $val, $sizes[$i]) } #endregion ===== Shared Helpers ===== 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 ) Test-Tool -Name $FfmpegPath Test-Tool -Name $FfprobePath $recurse = -not $NoRecurse.IsPresent $extensions = @('.gif','.webp','.mp4','.m4v','.mov','.mkv','.apng') 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($_) } } $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 } # Initialize progress tracking $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 "" } # Choose engine $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 { $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 } $records = Invoke-WebMParallel -Files $files -S $settings } } if ($records.Count -eq 0) { # Sequential engine foreach ($f in $files) { # Progress output 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 } $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 { $dest = Get-DestinationPath -SourceFile $f -Root $Root -OutputRoot $OutputRoot -NewExtension '.webm' $result.Destination = $dest 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 } } $info = Invoke-FFProbeJson -Path $f.FullName -FfprobePath $FfprobePath $hasAlpha = $false 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 $srcW = Get-Width -Info $info $srcH = Get-Height -Info $info $srcFps = Get-FrameRate -Info $info $vf = $null $useFlatten = ($AlphaMode -eq 'disable' -and -not $AlphaBackground) $vf = Get-FilterGraph -SrcWidth $srcW -SrcFps $srcFps -MaxWidth $MaxWidth -MaxFPS $MaxFPS -AlphaMode $AlphaMode -FlattenBlack:$useFlatten $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') } } } if ($MaxBitrateKbps -gt 0) { $codecArgs += @('-maxrate', ("{0}k" -f $MaxBitrateKbps), '-bufsize', ("{0}k" -f ($MaxBitrateKbps*2))) } $args = @('-y','-hide_banner','-loglevel','error','-i', $f.FullName) $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 } } if (-not $filtersApplied) { if ($vf) { $args += @('-filter:v', $vf) } } $args += $codecArgs $args += @('-an','-f','webm', $dest) if ($WhatIfPreference) { Write-Host "WhatIf: would convert '$($f.FullName)' → '$dest'" $result.Status = 'WhatIf' $records += [pscustomobject]$result continue } & $FfmpegPath @args $ok = ($LASTEXITCODE -eq 0) if ($ok) { $result.Status = 'Converted' $dst = Get-Item $dest -ErrorAction SilentlyContinue if ($dst) { $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) } } } # Summary section # Totals for converted items only $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) } $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 if ($LogPath) { Write-LogRecords -Records $records -LogPath $LogPath } 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) } 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 } } } } } 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 ) Test-Tool -Name $FfmpegPath Test-Tool -Name $FfprobePath $recurse = -not $NoRecurse.IsPresent $extensions = @('.png','.jpg','.jpeg','.tif','.tiff','.bmp') 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($_) } } $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 } # Initialize progress tracking $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 "" } $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 { $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 } $records = Invoke-WebPParallel -Files $files -S $settings } } if ($records.Count -eq 0) { # Sequential foreach ($f in $files) { # Progress output 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 } $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 { $dest = Get-DestinationPath -SourceFile $f -Root $Root -OutputRoot $OutputRoot -NewExtension '.webp' $result.Destination = $dest 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 } } $info = Invoke-FFProbeJson -Path $f.FullName -FfprobePath $FfprobePath $srcW = Get-Width -Info $info $vf = $null if ($srcW -and $srcW -gt $MaxWidth) { $vf = "scale=min(iw\,${MaxWidth}):-2:flags=lanczos" } $args = @('-y','-hide_banner','-loglevel','error','-i', $f.FullName) if ($vf) { $args += @('-vf', $vf) } $args += @('-c:v','libwebp') if ($Lossless) { $args += @('-lossless','1','-compression_level','6') } else { $args += @('-q:v', $Quality) } $args += @('-frames:v','1', $dest) if ($WhatIfPreference) { Write-Host "WhatIf: would convert '$($f.FullName)' → '$dest'" $result.Status = 'WhatIf' $records += [pscustomobject]$result continue } & $FfmpegPath @args $ok = ($LASTEXITCODE -eq 0) if ($ok) { $result.Status = 'Converted' $dst = Get-Item $dest -ErrorAction SilentlyContinue if ($dst) { $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) } } } # Summary section # Totals for converted items only $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) } $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 if ($LogPath) { Write-LogRecords -Records $records -LogPath $LogPath } 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) } 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 } } } } } function Optimize-FileNames { [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory=$true)] [string]$Root, [switch]$NoRecurse, [string[]]$IncludeExt, [string[]]$ExcludeExt, [switch]$RemoveMetadata, [ValidateSet('Remove','Dash','Underscore')] [string]$SpaceReplacement = 'Underscore', [switch]$LowercaseExtensions, [switch]$PreserveCase, [string]$LogPath, [switch]$Silent, [switch]$Force ) # Validate root path exists if (-not (Test-Path -LiteralPath $Root)) { throw "Path not found: $Root" } # Initialize collections $renameOperations = @() $errors = @() $skipped = @() $renamedPaths = @{} # Track renamed paths for child file updates # Define problematic characters for web URIs $problematicChars = @( '*', '"', '[', ']', ':', ';', '|', ',', '&', '=', '+', '$', '?', '%', '#', '(', ')', '{', '}', '<', '>', '!', '@', '^', '~', '`', "'" ) # Get all files and directories $recurse = -not $NoRecurse.IsPresent $allItems = Get-ChildItem -LiteralPath $Root -Recurse:$recurse # Filter by extension if specified (for files only) if ($IncludeExt -or $ExcludeExt) { $includeSet = if ($IncludeExt) { [System.Collections.Generic.HashSet[string]]::new([string[]]($IncludeExt | ForEach-Object { $_.ToLower() })) } else { $null } $excludeSet = if ($ExcludeExt) { [System.Collections.Generic.HashSet[string]]::new([string[]]($ExcludeExt | ForEach-Object { $_.ToLower() })) } else { $null } $allItems = $allItems | Where-Object { if ($_.PSIsContainer) { return $true } # Always include directories $ext = [System.IO.Path]::GetExtension($_.Name).ToLower() $include = if ($includeSet) { $includeSet.Contains($ext) } else { $true } $exclude = if ($excludeSet) { $excludeSet.Contains($ext) } else { $false } return $include -and -not $exclude } } # Sort items - directories first (deepest first for renaming), then files $directories = $allItems | Where-Object { $_.PSIsContainer } | Sort-Object { $_.FullName.Split('\').Count } -Descending $files = $allItems | Where-Object { -not $_.PSIsContainer } | Sort-Object FullName # Process directories first, then files $itemsToProcess = @($directories) + @($files) # Initialize progress $totalItems = $itemsToProcess.Count $itemNum = 0 if (-not $Silent -and $totalItems -gt 0) { Write-Host "" Write-Host "=== Optimize-FileNames Starting ===" -ForegroundColor Cyan Write-Host "Analyzing $totalItems item(s) for optimization..." -ForegroundColor Yellow Write-Host "" } # Helper function to sanitize names function Get-SanitizedName { param( [string]$Name, [string]$Extension = "" ) # Start with original name $newName = $Name # Step 1: Remove metadata if requested if ($RemoveMetadata) { $newName = $newName -replace '\([^)]*\)', '' # Remove (metadata) $newName = $newName -replace '\[[^\]]*\]', '' # Remove [metadata] $newName = $newName -replace '_\d+x\d+', '' # Remove _100x100 dimensions $newName = $newName -replace '-\d+x\d+', '' # Remove -100x100 dimensions $newName = $newName -replace '__+', '_' # Collapse multiple underscores $newName = $newName -replace '--+', '-' # Collapse multiple dashes $newName = $newName -replace '[_-]+$', '' # Remove trailing separators $newName = $newName -replace '^[_-]+', '' # Remove leading separators } # Step 2: Handle spaces switch ($SpaceReplacement) { 'Remove' { $newName = $newName -replace '\s+', '' } 'Dash' { $newName = $newName -replace '\s+', '-' } 'Underscore' { $newName = $newName -replace '\s+', '_' } } # Step 3: Remove or replace problematic characters foreach ($char in $problematicChars) { $escaped = [Regex]::Escape($char) $newName = $newName -replace $escaped, '_' } # Step 4: Clean up multiple separators $newName = $newName -replace '_{2,}', '_' $newName = $newName -replace '-{2,}', '-' $newName = $newName -replace '\.{2,}', '.' $newName = $newName -replace '^[_.-]+', '' $newName = $newName -replace '[_.-]+$', '' # Step 5: Handle case conversion if (-not $PreserveCase) { $newName = $newName.ToLower() } # Step 6: Handle extension $newExt = $Extension if ($LowercaseExtensions -and $Extension) { $newExt = $Extension.ToLower() } # Combine name and extension if ($Extension) { $finalName = "${newName}${newExt}" } else { $finalName = $newName } # Ensure we have a valid name if ([string]::IsNullOrWhiteSpace($finalName)) { $finalName = "unnamed_$(Get-Random -Maximum 9999)" if ($Extension) { $finalName += $newExt } } return $finalName } # Process each item foreach ($item in $itemsToProcess) { $itemNum++ # Progress output if (-not $Silent) { $percentComplete = [math]::Round(($itemNum / $totalItems) * 100, 0) $itemType = if ($item.PSIsContainer) { "Dir" } else { "File" } Write-Host ("[{0,3}%] Checking {1}/{2}: [{3}] {4}" -f $percentComplete, $itemNum, $totalItems, $itemType, $item.Name) -ForegroundColor Cyan } # Get current path (may have been updated if parent was renamed) $currentPath = $item.FullName foreach ($oldPath in $renamedPaths.Keys | Sort-Object -Property Length -Descending) { if ($currentPath.StartsWith($oldPath)) { $currentPath = $currentPath.Replace($oldPath, $renamedPaths[$oldPath]) break } } # Skip if item no longer exists (parent was renamed) if (-not (Test-Path -LiteralPath $currentPath)) { if (-not $Silent) { Write-Host " ⚠ Skipped: Parent directory was renamed" -ForegroundColor Yellow } continue } # Get updated item $currentItem = Get-Item -LiteralPath $currentPath # Get original name components $originalName = $currentItem.Name $isDirectory = $currentItem.PSIsContainer # Get parent directory if ($isDirectory) { $directory = $currentItem.Parent.FullName if (-not $directory) { $directory = Split-Path $currentPath -Parent } } else { $directory = $currentItem.DirectoryName } # For files, separate name and extension if (-not $isDirectory) { $nameWithoutExt = [System.IO.Path]::GetFileNameWithoutExtension($originalName) $extension = [System.IO.Path]::GetExtension($originalName) } else { $nameWithoutExt = $originalName $extension = "" } # Get sanitized name $newName = Get-SanitizedName -Name $nameWithoutExt -Extension $extension # Check if rename is needed if ($newName -eq $originalName) { if (-not $Silent) { Write-Host " ✓ Already optimized" -ForegroundColor Green } continue } # Build the new full path $newPath = Join-Path $directory $newName # Check if target already exists if ((Test-Path -LiteralPath $newPath) -and ($currentPath -ne $newPath) -and -not $Force) { if (-not $Silent) { Write-Host " ⚠ Skipped: Target name already exists: $newName" -ForegroundColor Yellow } $skipped += [PSCustomObject]@{ Original = $currentPath Proposed = $newPath Reason = "Target exists" } continue } # Create rename operation record $operation = [PSCustomObject]@{ Time = (Get-Date).ToString('s') Type = if ($isDirectory) { "Directory" } else { "File" } OriginalPath = $currentPath OriginalName = $originalName NewPath = $newPath NewName = $newName Status = "Pending" Error = "" } # Perform rename if ($PSCmdlet.ShouldProcess($currentPath, "Rename to $newName")) { try { # Handle existing file if Force is specified if ((Test-Path -LiteralPath $newPath) -and ($currentPath -ne $newPath) -and $Force) { if (-not $Silent) { Write-Host " ⚠ Overwriting existing: $newName" -ForegroundColor Yellow } Remove-Item -LiteralPath $newPath -Force } # Perform the rename Rename-Item -LiteralPath $currentPath -NewName $newName -Force:$Force -ErrorAction Stop $operation.Status = "Success" # Track renamed directories for updating child paths if ($isDirectory) { $renamedPaths[$currentPath] = $newPath } if (-not $Silent) { Write-Host " ✓ Renamed: $originalName → $newName" -ForegroundColor Green } } catch { $operation.Status = "Failed" $operation.Error = $_.Exception.Message $errors += $operation if (-not $Silent) { Write-Host " ✗ Failed: $($_.Exception.Message)" -ForegroundColor Red } } } else { $operation.Status = "WhatIf" if (-not $Silent) { Write-Host " → Would rename: $originalName → $newName" -ForegroundColor Cyan } } $renameOperations += $operation } # Write log if requested if ($LogPath) { $dir = [System.IO.Path]::GetDirectoryName($LogPath) if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } $logData = @{ Timestamp = (Get-Date).ToString('s') Root = $Root TotalItems = $totalItems Operations = $renameOperations Skipped = $skipped Settings = @{ SpaceReplacement = $SpaceReplacement RemoveMetadata = $RemoveMetadata LowercaseExtensions = $LowercaseExtensions PreserveCase = $PreserveCase } } $ext = [System.IO.Path]::GetExtension($LogPath).ToLower() switch ($ext) { '.json' { $logData | ConvertTo-Json -Depth 5 | Set-Content -Path $LogPath -Encoding UTF8 } default { $renameOperations | Export-Csv -NoTypeInformation -Path $LogPath -Encoding UTF8 } } } # Summary $successful = ($renameOperations | Where-Object { $_.Status -eq "Success" }).Count $failed = ($renameOperations | Where-Object { $_.Status -eq "Failed" }).Count $whatif = ($renameOperations | Where-Object { $_.Status -eq "WhatIf" }).Count $skippedCount = $skipped.Count if (-not $Silent) { Write-Host "" } Write-Host "=== Optimize-FileNames Summary ===" -ForegroundColor Cyan Write-Host "Renamed: $successful" -ForegroundColor Green Write-Host "Skipped: $skippedCount" -ForegroundColor Yellow if ($whatif -gt 0) { Write-Host "WhatIf: $whatif" -ForegroundColor Cyan } if ($failed -gt 0) { Write-Host "Failed: $failed" -ForegroundColor Red } if ($errors.Count -gt 0) { Write-Host "" Write-Host "Errors encountered:" -ForegroundColor Red foreach ($err in $errors) { Write-Host " - $($err.OriginalName): $($err.Error)" -ForegroundColor Red } } # Return summary object return [PSCustomObject]@{ TotalItems = $totalItems Renamed = $successful Skipped = $skippedCount Failed = $failed WhatIf = $whatif Operations = $renameOperations } } Export-ModuleMember -Function Convert-ToWebM, Convert-ToWebP, Optimize-FileNames |