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)
            
            $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)
                        
            $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 @()
    }
}

# Parallel helper for filename optimization
function Invoke-FileNameOptimizationParallel {
    param(
        [System.IO.FileSystemInfo[]]$Files,  # Only files, directories handled sequentially
        [hashtable]$Settings,
        [hashtable]$RenamedPaths,
        [ref]$OperationId,
        [string]$OutputRoot = $null,
        [string]$RootFull = $null
    )
    
    Import-Module ThreadJob -ErrorAction SilentlyContinue
    Write-Verbose "Engine: ThreadJob (Filename Optimization) | ThrottleLimit=$($Settings.ThrottleLimit)"
    [System.Collections.ArrayList]$jobs = @()

    # Split files into batches for parallel processing (like WebM/WebP functions)
    $batchSize = [Math]::Max(1, [Math]::Ceiling($Files.Count / [Math]::Max(1, $Settings.ThrottleLimit)))
    Write-Verbose "Processing $($Files.Count) files in batches of $batchSize"
    
    for ($i = 0; $i -lt $Files.Count; $i += $batchSize) {
        $endIndex = [Math]::Min($i + $batchSize - 1, $Files.Count - 1)
        $batch = $Files[$i..$endIndex]
        
        while ($jobs.Count -ge [int]$Settings.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($FileBatch, $Settings, $RenamedPaths, $StartingOpId, $OutputRoot, $RootFull)
                        
            $VerbosePreference = $Settings.VerbosePreference
            $WhatIfPreference = $Settings.WhatIfPreference
            $ErrorActionPreference = 'Stop'
            $useOutputRoot = -not [string]::IsNullOrWhiteSpace($OutputRoot)
            $results = @()
            $currentOpId = $StartingOpId

            # Create the sanitization function within the job
            function Get-SanitizedName {
                param(
                    [string]$Name,
                    [string]$Extension = ""
                )
                
                $newName = $Name
                
                if ($Settings.RemoveMetadata) {
                    $newName = $newName -replace '\([^)]*\)', ''
                    $newName = $newName -replace '\[[^\]]*\]', ''
                    $newName = $newName -replace '_\d+x\d+', ''
                    $newName = $newName -replace '-\d+x\d+', ''
                    $newName = $newName -replace '__+', '_'
                    $newName = $newName -replace '--+', '-'
                    $newName = $newName -replace '[_-]+$', ''
                    $newName = $newName -replace '^[_-]+', ''
                }
                
                switch ($Settings.SpaceReplacement) {
                    'Remove'     { $newName = $newName -replace '\s+', '' }
                    'Dash'       { $newName = $newName -replace '\s+', '-' }
                    'Underscore' { $newName = $newName -replace '\s+', '_' }
                }
                
                if ($Settings.ExpandAmpersand) {
                    $newName = $newName -replace '&', '_and_'
                    $newName = $newName -replace '_and_+', '_and_'
                } else {
                    $newName = $newName -replace '&', '_'
                }
                
                # Define problematic characters
                $problematicChars = @(
                    '*', '"', '[', ']', ':', ';', '|', ',', '&', '=', '+', '$', '?', '%', '#',
                    '(', ')', '{', '}', '<', '>', '!', '@', '^', '~', '`', "'"
                )
                
                foreach ($char in $problematicChars) {
                    if ($char -ne '&') {
                        $escaped = [Regex]::Escape($char)
                        if ($char -in @('[',']','(',')')) {
                            $newName = $newName -replace $escaped, '-'
                        } else {
                            $newName = $newName -replace $escaped, ''
                        }
                    }
                }
                
                $newName = $newName -replace '_{2,}', '_'
                $newName = $newName -replace '-{2,}', '-'
                $newName = $newName -replace '\.{2,}', '.'
                $newName = $newName -replace '^[_.-]+', ''
                $newName = $newName -replace '[_.-]+$', ''
                
                if (-not $Settings.PreserveCase) {
                    $newName = $newName.ToLower()
                }
                
                $newExt = $Extension
                if ($Settings.LowercaseExtensions -and $Extension) {
                    $newExt = $Extension.ToLower()
                }
                
                if ($Extension) {
                    $finalName = "${newName}${newExt}"
                } else {
                    $finalName = $newName
                }
                
                if ([string]::IsNullOrWhiteSpace($finalName)) {
                    $finalName = "unnamed_$(Get-Random -Maximum 9999)"
                    if ($Extension) { $finalName += $newExt }
                }
                
                return $finalName
            }

            # Process each file in the batch
            foreach ($f in $FileBatch) {
                $currentOpId++
                
                # Update file path based on renamed directories
                $currentPath = $f.FullName
                $originalPath = $f.FullName
                foreach ($oldPath in $RenamedPaths.Keys | Sort-Object -Property Length -Descending) {
                    if ($currentPath.StartsWith($oldPath)) {
                        $currentPath = $currentPath.Replace($oldPath, $RenamedPaths[$oldPath])
                        Write-Verbose "File path mapped: '$originalPath' -> '$currentPath'"
                        break
                    }
                }
                
                # For OutputRoot operations, we don't skip files just because their path was mapped
                # The mapped path should still be processed normally
                if (-not $useOutputRoot -and -not (Test-Path -LiteralPath $currentPath)) {
                    $results += [PSCustomObject]@{
                        OperationId = $currentOpId
                        Type = "File"
                        OriginalPath = $f.FullName
                        OriginalName = $f.Name
                        NewPath = $null
                        NewName = $null
                        Status = "Skipped"
                        Error = "Parent directory was renamed"
                        Time = (Get-Date).ToString('s')
                        LastWriteTime = $null
                        FileSize = $null
                        ParentDirectory = $null
                        Dependencies = @()
                    }
                    continue
                }
                
                # For OutputRoot operations with mapped paths, use original file info
                $currentItem = if ($useOutputRoot) {
                    # Always use original file info for OutputRoot operations
                    $f
                } else {
                    Get-Item -LiteralPath $currentPath
                }
                
                $originalName = $currentItem.Name
                # Use the mapped directory path if the directory was renamed
                $directory = $currentItem.DirectoryName
                foreach ($oldPath in $RenamedPaths.Keys | Sort-Object -Property Length -Descending) {
                    if ($directory.StartsWith($oldPath)) {
                        $directory = $directory.Replace($oldPath, $RenamedPaths[$oldPath])
                        Write-Verbose "Directory path for file mapped: '$($currentItem.DirectoryName)' -> '$directory'"
                        break
                    }
                }
                
                $nameWithoutExt = [System.IO.Path]::GetFileNameWithoutExtension($originalName)
                $extension = [System.IO.Path]::GetExtension($originalName)
                
                $newName = Get-SanitizedName -Name $nameWithoutExt -Extension $extension
                
                # Calculate new path based on OutputRoot if specified
                if ($useOutputRoot) {
                    # Calculate relative path from Root to file's directory
                    $rootUri = New-Object System.Uri(($RootFull + '\'))
                    $dirUri = New-Object System.Uri(($directory + '\'))
                    $relUri = $rootUri.MakeRelativeUri($dirUri).ToString()
                    $relPath = [System.Uri]::UnescapeDataString($relUri) -replace '/', '\'
                    
                    $destDir = if ([string]::IsNullOrWhiteSpace($relPath)) { 
                        $OutputRoot 
                    } else { 
                        Join-Path $OutputRoot $relPath 
                    }
                    
                    # Create destination directory if needed
                    if (-not (Test-Path -LiteralPath $destDir)) {
                        New-Item -ItemType Directory -Force -Path $destDir | Out-Null
                    }
                    
                    $newPath = Join-Path $destDir $newName
                } else {
                    $newPath = Join-Path $directory $newName
                }
                
                # Create result object
                $result = [PSCustomObject]@{
                    OperationId = $currentOpId
                    Type = "File"
                    OriginalPath = $f.FullName
                    OriginalName = $originalName
                    NewPath = $newPath
                    NewName = $newName
                    Status = "Skipped"
                    Error = ""
                    Time = (Get-Date).ToString('s')
                    LastWriteTime = $currentItem.LastWriteTimeUtc.ToString('o')
                    FileSize = $currentItem.Length
                    ParentDirectory = $directory
                    Dependencies = @()
                }
                
                # Check if rename is needed
                if ($newName -eq $originalName) {
                    $result.Status = "AlreadyOptimized"
                    $results += $result
                    continue
                }
                
                # Check for conflicts
                if ((Test-Path -LiteralPath $newPath) -and ($currentPath -ne $newPath) -and -not $Settings.Force) {
                    $result.Status = "Skipped"
                    $result.Error = "Target already exists"
                    $results += $result
                    continue
                }
                
                # Perform operation
                if ($WhatIfPreference) {
                    $result.Status = "WhatIf"
                    $results += $result
                    continue
                }
                
                try {
                    if ($useOutputRoot) {
                        # Copy file to new location with new name
                        Copy-Item -LiteralPath $f.FullName -Destination $newPath -Force:$Settings.Force
                        $result.Status = "Success"
                    } else {
                        # Original rename logic for in-place operation
                        if ((Test-Path -LiteralPath $newPath) -and ($currentPath -ne $newPath) -and $Settings.Force) {
                            Remove-Item -LiteralPath $newPath -Force
                        }
                        
                        Rename-Item -LiteralPath $currentPath -NewName $newName -Force:$Settings.Force -ErrorAction Stop
                        $result.Status = "Success"
                    }
                    
                    $results += $result
                } catch {
                    $result.Status = "Failed"
                    $result.Error = $_.Exception.Message
                    $results += $result
                }
            }
            
            return $results
        } -ArgumentList $batch, $Settings, $RenamedPaths, $OperationId.Value, $OutputRoot, $RootFull
        
        # Update operation ID for next batch
        $OperationId.Value += $batch.Count
        $jobs += $job
    }

    if ($jobs.Count -gt 0) {
        Wait-Job -Job $jobs | Out-Null
        $allResults = @()
        $results = Receive-Job -Job $jobs -AutoRemoveJob -Wait
        # Flatten results from all batches
        foreach ($result in $results) {
            if ($result -is [array]) {
                $allResults += $result
            } else {
                $allResults += $result
            }
        }
        return $allResults
    } else {
        return @()
    }
}