<# .SYNOPSIS Clones (copies) an image file using ffmpeg. Supports PNG, JPEG, WEBP, BMP, AVIF and TIFF. .PARAMETER Source Path to the input image file. Can be absolute or relative to current directory. If the path has no extension, .png, .jpg, .jpeg, .webp, .bmp, .avif, .tif and .tiff are tried in turn. .PARAMETER Target Path to the output image file. Can be absolute or relative to current directory. If the path has no recognised image extension, .png is appended. .PARAMETER Size Size of the output image. Uses the input image's size if not specified. Possible values: 480p, 720p, 1080p, HD, 1440p, 2K, 2160p, 4K, or custom size in format "width:height". When specified, the source is scaled (preserving aspect ratio) so it fully covers the requested width and height, then centre-cropped to the exact target size. The original pixel aspect ratio is preserved (no warping). .PARAMETER Quality Quality factor value for lossy output formats (jpeg, webp and avif). Lower values mean better quality. - jpeg: passed to -q:v (typical range 1-31, lower is better). - webp: inverted internally and passed to libwebp's -quality (0-100, higher is better) so the "lower = better quality" semantic is consistent across formats. - avif: passed to libaom-av1's -crf (0-63, lower is better). Ignored for png, bmp and tiff (lossless). .PARAMETER Compression Compression level for png and webp formats. For png, this is libpng's -compression_level (0-9, higher means smaller files but slower encoding). For webp, this is libwebp's -compression_level (0-6, higher means smaller files but slower encoding). Ignored for jpeg, avif, bmp and tiff. .PARAMETER Metadata When present, copies available metadata from the source image file to the target image file. .PARAMETER Upscale Optional upscaling factor (2, 3 or 4) applied to the source image with upscayl-bin.exe before ffmpeg runs. The upscaled image is written to a Windows temp PNG and used as the ffmpeg input, so any -Size / -LTX cover-scale-then-crop is applied to the upscaled image. Requires upscayl-bin.exe in PATH, the UPSCAYL_HOME environment variable pointing to the upscayl-bin install directory, and the NCNN_GPU_ID environment variable set to the desired GPU device ID (-1 for CPU, 0/1/2/... for GPU). .PARAMETER Model Upscayl model name (without extension) to use when -Upscale is specified. Defaults to '4xNomos8kSC'. The model files (.bin and .param) must exist under %UPSCAYL_HOME%\models. Ignored when -Upscale is not specified. .PARAMETER Keep Optional path to save a copy of the upscayl-bin output (i.e. the upscaled image, before ffmpeg's cover-scale-then-crop). The file extension determines the format; if it differs from .png (the upscayl output format) the temp PNG is converted with ffmpeg using the appropriate per-format encoder. If the extension is missing or unrecognised, .png is appended. Only valid when -Upscale is specified. .PARAMETER LTX When present, overrides the dimensions selected by -Size with the closest LTX-2 (Wan2GP by DeepBeepMeep) canonical resolution for the source's aspect ratio. Only valid when -Size is one of HD, 1080p, 720p or 480p. The closest aspect ratio is picked from the following buckets: - HD / 1080p: 1920x1088 (16:9), 1088x1920 (9:16), 1664x960 (21:9), 960x1664 (9:21) - 720p: 1280x704 (16:9), 704x1280 (9:16), 1088x576 (21:9), 576x1088 (9:21) - 480p: 832x448 (16:9), 448x832 (9:16), 896x512 (4:3), 512x896 (3:4) The cover-scale-then-centre-crop logic still applies, so the source is fitted to the picked LTX dimensions while preserving the original pixel aspect ratio (no warping). .DESCRIPTION This script uses ffmpeg to clone (copy) an image file with optional size change and quality/compression settings. Supports -WhatIf and -Confirm for safe execution. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory = $true)] [string]$Source, [Parameter(Mandatory = $true)] [string]$Target, [Parameter(Mandatory = $false)] [string]$Size = $null, [Parameter(Mandatory = $false)] [int]$Quality = -1, [Parameter(Mandatory = $false)] [int]$Compression = -1, [Parameter(Mandatory = $false)] [switch]$Metadata, [Parameter(Mandatory = $false)] [switch]$LTX, [Parameter(Mandatory = $false)] [ValidateSet(0, 2, 3, 4)] [int]$Upscale = 0, [Parameter(Mandatory = $false)] [string]$Model = '4xNomos8kSC', [Parameter(Mandatory = $false)] [string]$Keep = $null ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $SourcePath = $Source # Resolve source path: accept common image extensions. If the path doesn't # exist as-is, try appending each supported extension in turn. $supportedSourceExts = @('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.avif', '.tif', '.tiff') if (-not (Test-Path -LiteralPath $SourcePath)) { $resolved = $null foreach ($ext in $supportedSourceExts) { $candidate = "$SourcePath$ext" if (Test-Path -LiteralPath $candidate) { $resolved = $candidate break } } if ($resolved) { $SourcePath = $resolved } } if (-not (Test-Path -LiteralPath $SourcePath)) { Write-Error ('Source image file not found: {0}' -f $Source) exit 1 } $TargetPath = $Target # Determine output format from the target extension. Unrecognised/missing # extensions fall back to .png. $supportedTargetExts = @('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.avif', '.tif', '.tiff') $currentTargetExt = [System.IO.Path]::GetExtension($TargetPath).ToLowerInvariant() if ($supportedTargetExts -notcontains $currentTargetExt) { $TargetPath += '.png' $currentTargetExt = '.png' } # Normalise extension to a single format key. switch ($currentTargetExt) { '.jpeg' { $targetFormat = 'jpeg' } '.jpg' { $targetFormat = 'jpeg' } '.tif' { $targetFormat = 'tiff' } '.tiff' { $targetFormat = 'tiff' } default { $targetFormat = $currentTargetExt.TrimStart('.') } } $TargetDir = Split-Path -Parent $TargetPath if ($TargetDir -and -not (Test-Path -LiteralPath $TargetDir)) { Write-Error ('Target directory does not exist: {0}' -f $TargetDir) exit 1 } $sizeMap = @{ '480p' = @{ Width = 854; Height = 480 } '720p' = @{ Width = 1280; Height = 720 } '1080p' = @{ Width = 1920; Height = 1080 } 'HD' = @{ Width = 1920; Height = 1080 } '1440p' = @{ Width = 2560; Height = 1440 } '2K' = @{ Width = 2560; Height = 1440 } '2160p' = @{ Width = 3840; Height = 2160 } '4K' = @{ Width = 3840; Height = 2160 } } $scaleWidth = 0 $scaleHeight = 0 if (-not [string]::IsNullOrWhiteSpace($Size)) { if ($sizeMap.ContainsKey($Size)) { $scaleWidth = $sizeMap[$Size].Width $scaleHeight = $sizeMap[$Size].Height } elseif ($Size -match '^(\d+):(\d+)$') { $scaleWidth = [int]$Matches[1] $scaleHeight = [int]$Matches[2] } else { Write-Error ('Invalid Size value: {0}. Possible values: 480p, 720p, 1080p, HD, 1440p, 2K, 2160p, 4K, or custom size in format "width:height".' -f $Size) exit 1 } } $PSNativeCommandUseErrorActionPreference = $false # Probe source image dimensions. Used both for informational output and (when # -LTX is set) to select the closest LTX-2 canonical resolution. $ffprobeDimOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x -- $SourcePath 2>&1 $sourceDimensions = '' $sourceWidth = 0 $sourceHeight = 0 if ($LASTEXITCODE -eq 0) { $dimLine = $ffprobeDimOutput | Select-Object -First 1 if ($dimLine) { $sourceDimensions = ([string]$dimLine).Trim() if ($sourceDimensions -match '^(\d+)x(\d+)$') { $sourceWidth = [int]$Matches[1] $sourceHeight = [int]$Matches[2] } } } if ($LTX) { $ltxKey = $Size if ($ltxKey -eq 'HD') { $ltxKey = '1080p' } $ltxMap = @{ '1080p' = @( @{ W = 1920; H = 1088 }, @{ W = 1088; H = 1920 }, @{ W = 1664; H = 960 }, @{ W = 960; H = 1664 } ) '720p' = @( @{ W = 1280; H = 704 }, @{ W = 704; H = 1280 }, @{ W = 1088; H = 576 }, @{ W = 576; H = 1088 } ) '480p' = @( @{ W = 832; H = 448 }, @{ W = 448; H = 832 }, @{ W = 896; H = 512 }, @{ W = 512; H = 896 } ) } if (-not $ltxMap.ContainsKey($ltxKey)) { Write-Error '-LTX requires -Size to be one of: 480p, 720p, 1080p, HD' exit 1 } if ($sourceWidth -le 0 -or $sourceHeight -le 0) { Write-Error 'Could not determine source image dimensions; -LTX cannot pick an aspect-ratio bucket.' exit 1 } $srcRatio = [double]$sourceWidth / [double]$sourceHeight $best = $null $bestDiff = [double]::MaxValue foreach ($entry in $ltxMap[$ltxKey]) { $r = [double]$entry.W / [double]$entry.H $d = [Math]::Abs($r - $srcRatio) if ($d -lt $bestDiff) { $bestDiff = $d $best = $entry } } $scaleWidth = $best.W $scaleHeight = $best.H } # Build the per-format encoder args. Quality is always interpreted as # "lower = better" from the user's point of view; format-specific mapping # happens here. $encoderArgs = @() switch ($targetFormat) { 'jpeg' { $encoderArgs += @('-c:v', 'mjpeg', '-pix_fmt', 'yuvj420p') if ($Quality -ge 0) { $encoderArgs += @('-q:v', $Quality.ToString()) } else { $encoderArgs += @('-q:v', '2') } } 'png' { $encoderArgs += @('-c:v', 'png') if ($Compression -ge 0) { $encoderArgs += @('-compression_level', $Compression.ToString()) } } 'webp' { $encoderArgs += @('-c:v', 'libwebp') if ($Quality -ge 0) { # Invert so that "lower = better" matches JPEG/AVIF behaviour. $webpQuality = 100 - $Quality if ($webpQuality -lt 0) { $webpQuality = 0 } if ($webpQuality -gt 100) { $webpQuality = 100 } $encoderArgs += @('-quality', $webpQuality.ToString()) } else { $encoderArgs += @('-quality', '90') } if ($Compression -ge 0) { $encoderArgs += @('-compression_level', $Compression.ToString()) } } 'avif' { $encoderArgs += @('-c:v', 'libaom-av1', '-still-picture', '1', '-cpu-used', '4') if ($Quality -ge 0) { $encoderArgs += @('-crf', $Quality.ToString()) } else { $encoderArgs += @('-crf', '25') } } 'bmp' { $encoderArgs += @('-c:v', 'bmp') } 'tiff' { $encoderArgs += @('-c:v', 'tiff') } } # --- Optional upscayl-bin upscaling step (runs before ffmpeg). --- # When -Upscale is given (2/3/4) the source image is upscaled with upscayl-bin # into a temp PNG; that temp PNG then becomes the ffmpeg input so any -Size / # -LTX cover-scale-then-crop is applied to the already-upscaled image. $upscaledTempPath = $null if ($Upscale -gt 0) { $upscaylExe = Get-Command -Name 'upscayl-bin.exe' -ErrorAction SilentlyContinue if (-not $upscaylExe) { Write-Error 'upscayl-bin.exe not found in PATH. Install upscayl-bin and add it to the system PATH (see mkdocs/Upscalers.md).' exit 1 } $upscaylExePath = $upscaylExe.Source $upscaylHome = $env:UPSCAYL_HOME if (-not $upscaylHome) { Write-Error 'UPSCAYL_HOME environment variable is not set. Set it to the upscayl-bin install directory.' exit 1 } $upscaylHome = $upscaylHome.TrimEnd('\', '/') if (-not (Test-Path -LiteralPath $upscaylHome -PathType Container)) { Write-Error ('UPSCAYL_HOME directory does not exist: {0}' -f $upscaylHome) exit 1 } $modelsDir = Join-Path $upscaylHome 'models' if (-not (Test-Path -LiteralPath $modelsDir -PathType Container)) { Write-Error ('Upscayl models directory not found: {0}' -f $modelsDir) exit 1 } $modelBin = Join-Path $modelsDir ('{0}.bin' -f $Model) $modelParam = Join-Path $modelsDir ('{0}.param' -f $Model) if (-not (Test-Path -LiteralPath $modelBin -PathType Leaf) -or -not (Test-Path -LiteralPath $modelParam -PathType Leaf)) { Write-Error ('Upscayl model files not found for model ''{0}'' under {1} (expected {0}.bin and {0}.param).' -f $Model, $modelsDir) exit 1 } $gpuId = $env:NCNN_GPU_ID if (-not $gpuId) { Write-Error 'NCNN_GPU_ID environment variable is not set. Set it to -1 (CPU) or 0/1/2/... (GPU device index).' exit 1 } if ($gpuId -notmatch '^-?\d+$') { Write-Error ('NCNN_GPU_ID must be an integer (-1 for CPU, 0+ for GPU). Current value: {0}' -f $gpuId) exit 1 } $upscaledTempPath = Join-Path ([System.IO.Path]::GetTempPath()) (('upscayl-{0}.png') -f ([System.Guid]::NewGuid().ToString())) Write-Host ('Upscaling source with upscayl-bin (x{0}, model={1}, gpu={2})...' -f $Upscale, $Model, $gpuId) Write-Host (' Upscayl temp file: {0}' -f $upscaledTempPath) if ($PSCmdlet.ShouldProcess($SourcePath, ('Upscale x{0} with upscayl-bin into temp file' -f $Upscale))) { $upscaylOutput = & $upscaylExePath -i $SourcePath -o $upscaledTempPath -s $Upscale.ToString() -n $Model -m $modelsDir -g $gpuId 2>&1 $upscaylExit = $LASTEXITCODE if ($upscaylExit -ne 0) { Write-Host 'upscayl-bin error output:' -ForegroundColor Red $upscaylOutput | ForEach-Object { Write-Host $_ } Write-Error ('upscayl-bin failed with exit code: {0}' -f $upscaylExit) if (Test-Path -LiteralPath $upscaledTempPath) { Remove-Item -LiteralPath $upscaledTempPath -Force -ErrorAction SilentlyContinue } exit 1 } # Switch ffmpeg's input to the upscaled image. $SourcePath = $upscaledTempPath # Refresh informational source dimensions from the upscaled file. $ffprobeDimOutput2 = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x -- $SourcePath 2>&1 if ($LASTEXITCODE -eq 0) { $dimLine2 = $ffprobeDimOutput2 | Select-Object -First 1 if ($dimLine2) { $sourceDimensions = ([string]$dimLine2).Trim() + (' (upscaled x{0})' -f $Upscale) } } # --- Optional -Keep: save a copy of the upscaled image. --- if (-not [string]::IsNullOrWhiteSpace($Keep)) { $KeepPath = $Keep $keepExt = [System.IO.Path]::GetExtension($KeepPath).ToLowerInvariant() if ($supportedTargetExts -notcontains $keepExt) { $KeepPath += '.png' $keepExt = '.png' } $KeepDir = Split-Path -Parent $KeepPath if ($KeepDir -and -not (Test-Path -LiteralPath $KeepDir)) { Write-Error ('Keep target directory does not exist: {0}' -f $KeepDir) if (Test-Path -LiteralPath $upscaledTempPath) { Remove-Item -LiteralPath $upscaledTempPath -Force -ErrorAction SilentlyContinue } exit 1 } if ($keepExt -eq '.png') { # Upscayl already wrote a PNG; just copy. if ($PSCmdlet.ShouldProcess($KeepPath, 'Copy upscaled image (PNG)')) { Copy-Item -LiteralPath $upscaledTempPath -Destination $KeepPath -Force Write-Host ('Upscaled image saved to: {0}' -f $KeepPath) } } else { # Convert PNG -> requested format with ffmpeg, using sane defaults. switch ($keepExt) { '.jpg' { $keepFormat = 'jpeg' } '.jpeg' { $keepFormat = 'jpeg' } '.tif' { $keepFormat = 'tiff' } '.tiff' { $keepFormat = 'tiff' } default { $keepFormat = $keepExt.TrimStart('.') } } $keepEncoderArgs = @() switch ($keepFormat) { 'jpeg' { $keepEncoderArgs += @('-c:v', 'mjpeg', '-pix_fmt', 'yuvj420p', '-q:v', '2') } 'webp' { $keepEncoderArgs += @('-c:v', 'libwebp', '-quality', '90') } 'avif' { $keepEncoderArgs += @('-c:v', 'libaom-av1', '-still-picture', '1', '-cpu-used', '4', '-crf', '25') } 'bmp' { $keepEncoderArgs += @('-c:v', 'bmp') } 'tiff' { $keepEncoderArgs += @('-c:v', 'tiff') } 'png' { $keepEncoderArgs += @('-c:v', 'png') } } Write-Host ('Converting upscaled image to {0}: {1}' -f $keepFormat, $KeepPath) if ($PSCmdlet.ShouldProcess($KeepPath, ('Save upscaled image as {0}' -f $keepFormat))) { $keepFfmpegArgs = @('-y', '-i', $upscaledTempPath) + $keepEncoderArgs + @('-frames:v', '1', '-map_metadata', '-1', '--', $KeepPath) $keepFfmpegOutput = & ffmpeg @keepFfmpegArgs 2>&1 $keepFfmpegExit = $LASTEXITCODE if ($keepFfmpegExit -ne 0) { Write-Host 'ffmpeg error output (Keep step):' -ForegroundColor Red $keepFfmpegOutput | ForEach-Object { Write-Host $_ } Write-Error ('ffmpeg failed to save upscaled image (-Keep) with exit code: {0}' -f $keepFfmpegExit) if (Test-Path -LiteralPath $upscaledTempPath) { Remove-Item -LiteralPath $upscaledTempPath -Force -ErrorAction SilentlyContinue } exit 1 } Write-Host ('Upscaled image saved to: {0}' -f $KeepPath) } } } } } if (-not [string]::IsNullOrWhiteSpace($Keep) -and $Upscale -le 0) { Write-Warning '-Keep was specified without -Upscale; ignoring (nothing to save).' } Write-Host ('Cloning image: {0}' -f $SourcePath) Write-Host (' Target format: {0}' -f $targetFormat) if ($sourceDimensions) { Write-Host (' Source size: {0}' -f $sourceDimensions) } if ($scaleWidth -gt 0) { if ($LTX) { Write-Host (' Target size: {0}x{1} (LTX-2, cover-scale then centre-crop)' -f $scaleWidth, $scaleHeight) } else { Write-Host (' Target size: {0}x{1} (cover-scale then centre-crop)' -f $scaleWidth, $scaleHeight) } } if ($Quality -ge 0) { Write-Host (' Quality: {0} (lower = better)' -f $Quality) } if ($Compression -ge 0) { Write-Host (' Compression: {0}' -f $Compression) } Write-Host (' Target: {0}' -f $TargetPath) $ffmpegArgs = @( '-y' '-i', $SourcePath ) if ($scaleWidth -gt 0) { # Cover-scale (force_original_aspect_ratio=increase guarantees the scaled # image fully covers WxH; the original pixel aspect ratio is preserved) # then centre-crop to the exact requested size. $videoFilter = 'scale={0}:{1}:force_original_aspect_ratio=increase,crop={0}:{1}' -f $scaleWidth, $scaleHeight $ffmpegArgs += @('-vf', $videoFilter) } $ffmpegArgs += $encoderArgs $ffmpegArgs += @('-frames:v', '1') if ($Metadata) { $ffmpegArgs += @('-map_metadata', '0') } else { $ffmpegArgs += @('-map_metadata', '-1') } $ffmpegArgs += @('--', $TargetPath) if ($PSCmdlet.ShouldProcess($TargetPath, 'Clone image file')) { $ffmpegOutput = & ffmpeg @ffmpegArgs 2>&1 $ffmpegExit = $LASTEXITCODE if ($ffmpegExit -ne 0) { Write-Host 'ffmpeg error output:' -ForegroundColor Red $ffmpegOutput | ForEach-Object { Write-Host $_ } Write-Error ('ffmpeg failed to clone image with exit code: {0}' -f $ffmpegExit) exit 1 } Write-Host ('Image cloned successfully to: {0}' -f $TargetPath) } # --- Clean up the upscayl temp file (if any). --- if ($upscaledTempPath -and (Test-Path -LiteralPath $upscaledTempPath)) { Remove-Item -LiteralPath $upscaledTempPath -Force -ErrorAction SilentlyContinue }