Private/SharedHelpers.ps1

<#
.SYNOPSIS
    ConvertVTTAssets SharedHelpers - Core utility functions for asset optimization operations
.DESCRIPTION
    Private module containing shared utility functions used across all ConvertVTTAssets operations.
    Includes tool validation, FFmpeg integration, file system operations, logging, and format helpers.
    These functions are internal to the module and not directly exposed to users.
.AUTHOR
    Andres Yuhnke, Claude (Anthropic)
.VERSION
    1.6.0
.DATE
    2025-08-24
.COPYRIGHT
    (c) 2025 Andres Yuhnke. MIT License.
.NOTES
    Private functions included:
    - Test-Tool: External tool validation (FFmpeg/FFprobe)
    - Invoke-FFProbeJson: Media metadata extraction
    - Get-HasAlpha, Get-FrameRate, Get-Width, Get-Height: Media property extraction
    - Get-FilterGraph: FFmpeg filter chain generation
    - Get-DestinationPath: Output path calculation with OutputRoot support
    - Move-ToRecycleBin: Safe file deletion using Windows Recycle Bin
    - Write-LogRecords: Flexible logging (CSV/JSON auto-detection)
    - Format-Bytes: Human-readable file size formatting
#>


# [HELP-001] External tool validation - Ensures required tools are available and executable
function Test-Tool {
    param([Parameter(Mandatory=$true)][string]$Name)
    
    # [HELP-001.1] Temporarily suppress error output to avoid cluttering console
    $ErrorActionPreferenceBackup = $ErrorActionPreference
    $ErrorActionPreference = 'SilentlyContinue'
    
    # [HELP-001.2] Test tool availability by calling with version parameter
    # Most tools support -version and return 0 exit code when working
    $null = & $Name -version 2>$null
    
    # [HELP-001.3] Restore original error preference after test
    $ErrorActionPreference = $ErrorActionPreferenceBackup
    
    # [HELP-001.4] Evaluate exit code to determine if tool is functional
    # Non-zero exit codes (except null) indicate tool not found or not executable
    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."
    }
}

# [HELP-002] FFmpeg probe integration - Extracts comprehensive media metadata as JSON
function Invoke-FFProbeJson {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)][string]$Path,
        [Parameter(Mandatory=$true)][string]$FfprobePath
    )
    
    # [HELP-002.1] Configure ffprobe for structured JSON output with video stream focus
    # -v error: Minimal logging, -print_format json: Machine-readable output
    # -select_streams v:0: First video stream only, -show_streams/-show_format: Complete metadata
    $args = @('-v','error','-print_format','json','-select_streams','v:0','-show_streams','-show_format',$Path)
    
    # [HELP-002.2] Execute ffprobe and capture JSON output, suppress stderr to avoid noise
    $json = & $FfprobePath @args 2>$null
    if (-not $json) { return $null }
    
    # [HELP-002.3] Parse JSON response with error handling for malformed output
    try { return $json | ConvertFrom-Json } catch { return $null }
}

# [HELP-003] Alpha channel detection - Determines if media contains transparency information
function Get-HasAlpha {
    param($Info)
    if (-not $Info) { return $false }
    
    # [HELP-003.1] Extract pixel format from first video stream metadata
    $fmt = $Info.streams[0].pix_fmt
    if (-not $fmt) { return $false }
    
    # [HELP-003.2] Check for alpha channel indicators in pixel format string
    # Simple 'a' check covers most cases, specific formats cover edge cases
    return ($fmt -match 'a') -or ($fmt -match 'rgba|bgra|argb|abgr|ya8|yuva420p|yuva422p|yuva444p')
}

# [HELP-004] Frame rate extraction - Gets video frame rate with fallback logic
function Get-FrameRate {
    param($Info)
    if (-not $Info) { return $null }
    
    # [HELP-004.1] Try average frame rate first (more accurate for variable rate content)
    $r = $Info.streams[0].avg_frame_rate
    
    # [HELP-004.2] Fall back to declared frame rate if average is unavailable
    if ([string]::IsNullOrWhiteSpace($r) -or $r -eq '0/0') {
        $r = $Info.streams[0].r_frame_rate
    }
    
    # [HELP-004.3] Handle invalid or missing frame rate data
    if (-not $r -or $r -eq '0/0') { return $null }
    
    # [HELP-004.4] Parse fractional frame rates (e.g., "30000/1001" for 29.97 fps)
    if ($r -match '^\d+/\d+$') {
        $num,$den = $r -split '/'
        if ([int]$den -ne 0) { return [double]$num / [double]$den }
        else { return $null }
    }
    
    # [HELP-004.5] Handle decimal frame rates with validation
    [double]::TryParse($r, [ref]([double]$fr = 0)) | Out-Null
    if ($fr -gt 0) { return $fr } else { return $null }
}

# [HELP-005] Media dimension extraction functions - Get width and height from metadata
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 }

# [HELP-006] FFmpeg filter graph generation - Creates video processing filter chains
function Get-FilterGraph {
    param(
        [int]$SrcWidth,
        [double]$SrcFps,
        [int]$MaxWidth,
        [int]$MaxFPS,
        [string]$AlphaMode = 'auto',
        [switch]$FlattenBlack
    )
    
    $filters = @()
    
    # [HELP-006.1] Add scaling filter if source exceeds maximum width
    # Uses Lanczos algorithm for high-quality downscaling, maintains aspect ratio
    if ($SrcWidth -and $SrcWidth -gt $MaxWidth) {
        $filters += "scale=min(iw\,${MaxWidth}):-2:flags=lanczos"
    }
    
    # [HELP-006.2] Add frame rate limiting filter for high FPS content
    if ($SrcFps -and ($SrcFps -gt $MaxFPS)) {
        $filters += "fps=${MaxFPS}"
    }
    
    # [HELP-006.3] Add alpha channel flattening for transparency removal
    if ($AlphaMode -eq 'disable' -and $FlattenBlack) {
        $filters += "format=yuv420p"
    }
    
    # [HELP-006.4] Combine filters into single filter graph string
    if ($filters.Count -gt 0) { return ($filters -join ',') }
    return $null
}

# [HELP-007] Destination path calculation - Handles OutputRoot mapping and directory creation
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
    )

    # [HELP-007.1] Simple case: No OutputRoot means in-place operation (same directory as source)
    if ([string]::IsNullOrWhiteSpace($OutputRoot)) {
        $destDir = $SourceFile.DirectoryName
    } else {
        # [HELP-007.2] Complex case: OutputRoot requires relative path calculation
        # Get absolute paths for reliable URI manipulation
        $rootFull = (Resolve-Path -LiteralPath $Root).Path.TrimEnd('\')
        $srcDirFull = (Resolve-Path -LiteralPath $SourceFile.DirectoryName).Path.TrimEnd('\')

        # [HELP-007.3] Calculate relative path from Root to source file's directory
        # Using URI objects ensures proper handling of spaces and special characters
        $rootUri = New-Object System.Uri(($rootFull + '\'))
        $dirUri  = New-Object System.Uri(($srcDirFull + '\'))
        $relUri  = $rootUri.MakeRelativeUri($dirUri).ToString()
        $relPath = [System.Uri]::UnescapeDataString($relUri) -replace '/', '\'

        # [HELP-007.4] Build destination directory path in OutputRoot
        if ([string]::IsNullOrWhiteSpace($relPath)) { $destDir = $OutputRoot }
        else { $destDir = Join-Path $OutputRoot $relPath }

        # [HELP-007.5] Create destination directory if it doesn't exist
        # Uses WhatIf support for preview operations
        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'"
            }
        }
    }

    # [HELP-007.6] Build final destination file path with new extension
    $destName = [System.IO.Path]::GetFileNameWithoutExtension($SourceFile.Name) + $NewExtension
    return (Join-Path $destDir $destName)
}

# [HELP-008] Recycle Bin integration - Safe file deletion using Windows shell integration
function Move-ToRecycleBin {
    [CmdletBinding(SupportsShouldProcess=$true)]
    param([Parameter(Mandatory=$true)][string]$Path)

    # [HELP-008.1] Load Visual Basic assembly for Recycle Bin operations
    # This provides access to the Windows shell recycling functionality
    try { Add-Type -AssemblyName Microsoft.VisualBasic -ErrorAction SilentlyContinue } catch {
        Write-Verbose "Add-Type already loaded or not available: $_"
    }

    # [HELP-008.2] Perform safe deletion using Windows Recycle Bin
    # Supports WhatIf for preview operations
    if ($PSCmdlet.ShouldProcess($Path, "Send to Recycle Bin")) {
        try {
            # [HELP-008.3] Use VB.NET FileSystem for reliable recycling
            # OnlyErrorDialogs: Minimal UI, SendToRecycleBin: Don't permanently delete
            [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
    }
}

# [HELP-009] Flexible logging system - Auto-detects format based on file extension
function Write-LogRecords {
    param(
        [Parameter(Mandatory=$true)][System.Collections.IEnumerable]$Records,
        [Parameter(Mandatory=$true)][string]$LogPath
    )
    
    # [HELP-009.1] Ensure log directory exists before writing
    $dir = [System.IO.Path]::GetDirectoryName($LogPath)
    if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }

    # [HELP-009.2] Auto-detect output format based on file extension
    $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 }  # Default to CSV
    }
}

# [HELP-010] Human-readable file size formatting - Converts bytes to appropriate units
function Format-Bytes {
    param([long]$Bytes)
    if ($Bytes -eq $null) { return '' }
    
    # [HELP-010.1] Define size units and initialize conversion variables
    $sizes = 'B','KB','MB','GB','TB'
    $i = 0; $val = [double]$Bytes
    
    # [HELP-010.2] Convert to appropriate unit (divide by 1024 until value < 1024)
    while ($val -ge 1024 -and $i -lt $sizes.Length-1) { $val /= 1024; $i++ }
    
    # [HELP-010.3] Format with 2 decimal places and appropriate unit suffix
    return ('{0:N2} {1}' -f $val, $sizes[$i])
}