ConvertVTTAssets.Core.ps1
# ConvertVTTAssets.Core.ps1 — PS7+ parallel helpers (ThreadJob engine) # Ensure ThreadJob is available Import-Module ThreadJob -ErrorAction SilentlyContinue function Invoke-WebMParallel { param( [System.IO.FileInfo[]]$Files, [hashtable]$S ) Import-Module ThreadJob -ErrorAction SilentlyContinue Write-Verbose "Engine: ThreadJob (WebM) | ThrottleLimit=$($S.ThrottleLimit)" [System.Collections.ArrayList]$jobs = @() foreach ($f in $Files) { while ($jobs.Count -ge [int]$S.ThrottleLimit) { if ($jobs.Count -gt 0) { Wait-Job -Job $jobs -Any | Out-Null } $jobs = @($jobs | Where-Object { $_.State -eq 'Running' }) } $job = Start-ThreadJob -ScriptBlock { param($f,$S) # Import the full module with retry logic $modulePath = 'C:\PowerShell-Scripts\ConvertVTTAssets\ConvertVTTAssets.psd1' $imported = $false for ($i = 0; $i -lt 3; $i++) { try { if (Test-Path $modulePath) { Import-Module $modulePath -Force -ErrorAction Stop # Verify a function exists if (Get-Command Get-DestinationPath -ErrorAction SilentlyContinue) { $imported = $true break } } Start-Sleep -Milliseconds 100 } catch { Start-Sleep -Milliseconds 100 } } if (-not $imported) { throw "Failed to import module after 3 attempts" } $VerbosePreference = $S.VerbosePreference $WhatIfPreference = $S.WhatIfPreference $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 = $S.Codec HasAlpha = $false AlphaMode = $S.AlphaMode FPSCap = $S.MaxFPS WidthCap = $S.MaxWidth } $sw = [System.Diagnostics.Stopwatch]::StartNew() try { $dest = Get-DestinationPath -SourceFile $f -Root $S.Root -OutputRoot $S.OutputRoot -NewExtension '.webm' $result.Destination = $dest if (-not $S.Force -and (Test-Path $dest)) { $dstInfo = Get-Item $dest if ($dstInfo.LastWriteTimeUtc -ge $f.LastWriteTimeUtc) { $result.Status = 'Skipped'; $result.Reason='UpToDate' return [pscustomobject]$result } } $info = Invoke-FFProbeJson -Path $f.FullName -FfprobePath $S.FfprobePath $hasAlpha = $false if ($info) { $hasAlpha = Get-HasAlpha -Info $info } elseif ($f.Extension.ToLower() -in @('.gif','.apng','.webp')) { $hasAlpha = $true } switch ($S.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 = ($S.AlphaMode -eq 'disable' -and -not $S.AlphaBackground) $vf = Get-FilterGraph -SrcWidth $srcW -SrcFps $srcFps -MaxWidth $S.MaxWidth -MaxFPS $S.MaxFPS -AlphaMode $S.AlphaMode -FlattenBlack:$useFlatten $codecArgs = @() switch ($S.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 ($S.MaxBitrateKbps -gt 0) { $codecArgs += @('-maxrate', ("{0}k" -f $S.MaxBitrateKbps), '-bufsize', ("{0}k" -f ($S.MaxBitrateKbps*2))) } $args = @('-y','-hide_banner','-loglevel','error','-i', $f.FullName) $filtersApplied = $false if ($S.AlphaMode -eq 'disable' -and $S.AlphaBackground) { $w = $srcW; $h = $srcH if ($w -and $h -and $w -gt 0 -and $h -gt 0) { $bg = ($S.AlphaBackground).Trim('#') $fc = @() if ($S.MaxFPS -gt 0) { $fc += ("fps={0}" -f $S.MaxFPS) } if ($S.MaxWidth -gt 0 -and $w -gt $S.MaxWidth) { $fc += ("scale=min(iw\,{0}):-2:flags=lanczos" -f $S.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' return [pscustomobject]$result } & $S.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) } } } else { $result.Status = 'Failed'; $result.Reason = "ffmpeg exit $LASTEXITCODE" } return [pscustomobject]$result } catch { $result.Status = 'Failed'; $result.Reason = $_.Exception.Message return [pscustomobject]$result } finally { $sw.Stop(); $result.DurationSec = [math]::Round($sw.Elapsed.TotalSeconds,2) } } -ArgumentList $f, $S $jobs += $job } if ($jobs.Count -gt 0) { Wait-Job -Job $jobs | Out-Null $out = Receive-Job -Job $jobs -AutoRemoveJob -Wait return $out } else { return @() } } function Invoke-WebPParallel { param( [System.IO.FileInfo[]]$Files, [hashtable]$S ) Import-Module ThreadJob -ErrorAction SilentlyContinue Write-Verbose "Engine: ThreadJob (WebP) | ThrottleLimit=$($S.ThrottleLimit)" [System.Collections.ArrayList]$jobs = @() foreach ($f in $Files) { while ($jobs.Count -ge [int]$S.ThrottleLimit) { if ($jobs.Count -gt 0) { Wait-Job -Job $jobs -Any | Out-Null } $jobs = @($jobs | Where-Object { $_.State -eq 'Running' }) } $job = Start-ThreadJob -ScriptBlock { param($f,$S) # Import the full module with retry logic $modulePath = 'C:\PowerShell-Scripts\ConvertVTTAssets\ConvertVTTAssets.psd1' $imported = $false for ($i = 0; $i -lt 3; $i++) { try { if (Test-Path $modulePath) { Import-Module $modulePath -Force -ErrorAction Stop # Verify a function exists if (Get-Command Get-DestinationPath -ErrorAction SilentlyContinue) { $imported = $true break } } Start-Sleep -Milliseconds 100 } catch { Start-Sleep -Milliseconds 100 } } if (-not $imported) { throw "Failed to import module after 3 attempts" } $VerbosePreference = $S.VerbosePreference $WhatIfPreference = $S.WhatIfPreference $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 = $S.Quality Lossless = [bool]$S.Lossless WidthCap = $S.MaxWidth } $sw = [System.Diagnostics.Stopwatch]::StartNew() try { $dest = Get-DestinationPath -SourceFile $f -Root $S.Root -OutputRoot $S.OutputRoot -NewExtension '.webp' $result.Destination = $dest if (-not $S.Force -and (Test-Path $dest)) { $dstInfo = Get-Item $dest if ($dstInfo.LastWriteTimeUtc -ge $f.LastWriteTimeUtc) { $result.Status = 'Skipped'; $result.Reason='UpToDate' return [pscustomobject]$result } } $info = Invoke-FFProbeJson -Path $f.FullName -FfprobePath $S.FfprobePath $srcW = Get-Width -Info $info $vf = $null if ($srcW -and $srcW -gt $S.MaxWidth) { $vf = "scale=min(iw\,{0}):-2:flags=lanczos" -f $S.MaxWidth } $args = @('-y','-hide_banner','-loglevel','error','-i', $f.FullName) if ($vf) { $args += @('-vf', $vf) } $args += @('-c:v','libwebp') if ($S.Lossless) { $args += @('-lossless','1','-compression_level','6') } else { $args += @('-q:v', $S.Quality) } $args += @('-frames:v','1', $dest) if ($WhatIfPreference) { Write-Host "WhatIf: would convert '$($f.FullName)' → '$dest'" $result.Status = 'WhatIf' return [pscustomobject]$result } & $S.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) } } } else { $result.Status = 'Failed'; $result.Reason = "ffmpeg exit $LASTEXITCODE" } return [pscustomobject]$result } catch { $result.Status = 'Failed'; $result.Reason = $_.Exception.Message return [pscustomobject]$result } finally { $sw.Stop(); $result.DurationSec = [math]::Round($sw.Elapsed.TotalSeconds,2) } } -ArgumentList $f, $S $jobs += $job } if ($jobs.Count -gt 0) { Wait-Job -Job $jobs | Out-Null $out = Receive-Job -Job $jobs -AutoRemoveJob -Wait return $out } else { return @() } } |