<# .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 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 ) 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') } } 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) }