From 1e585583088e845bac5d11d949838c28deb8dbb8 Mon Sep 17 00:00:00 2001 From: Claude Toupin Date: Wed, 10 Jun 2026 11:32:15 -0400 Subject: [PATCH] Add -LTX parameter to select closest LTX-2 canonical resolution for specified aspect ratio when using -Size with HD/1080p/720p/480p presets --- Clone-Image.cmd | 2 + Clone-Image.ps1 | 318 ++++++++++++++++++++++++++++++++++++++++++++++++ Clone-Video.ps1 | 80 +++++++++++- 3 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 Clone-Image.cmd create mode 100644 Clone-Image.ps1 diff --git a/Clone-Image.cmd b/Clone-Image.cmd new file mode 100644 index 0000000..ba0d6f6 --- /dev/null +++ b/Clone-Image.cmd @@ -0,0 +1,2 @@ +@echo off +pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Clone-Image.ps1" %* diff --git a/Clone-Image.ps1 b/Clone-Image.ps1 new file mode 100644 index 0000000..171b2f7 --- /dev/null +++ b/Clone-Image.ps1 @@ -0,0 +1,318 @@ +<# +.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) +} diff --git a/Clone-Video.ps1 b/Clone-Video.ps1 index 4ec12e9..e9a2859 100644 --- a/Clone-Video.ps1 +++ b/Clone-Video.ps1 @@ -41,6 +41,14 @@ Honours all other parameters (StartFrame/EndFrame range, Size, FPS, CRF, container choice, etc.). Audio is NOT reversed; it is processed exactly as it would be without -Reverse (trimmed/replaced/copied as applicable) and plays forward alongside the reversed video. Forces a video re-encode, since the reverse filter cannot be applied to a stream-copied video. +.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) + Forces a video re-encode (same as -Size). .DESCRIPTION This script uses ffmpeg to clone (copy) a video file with optional frame range and audio exclusion. When Sequence is provided, it extracts a specific frame to PNG and then clones the video accordingly. @@ -82,7 +90,10 @@ param( [switch]$Metadata, [Parameter(Mandatory = $false)] - [switch]$Reverse + [switch]$Reverse, + + [Parameter(Mandatory = $false)] + [switch]$LTX ) Set-StrictMode -Version Latest @@ -261,6 +272,67 @@ if (-not $totalFrames -or $totalFrames -notmatch '^\d+$') { $totalFramesInt = [int]$totalFrames $maxFrameIndex = $totalFramesInt - 1 +# Probe source video dimensions (used by -LTX to pick the closest aspect-ratio bucket). +$ffprobeDimOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x -- $SourcePath 2>&1 +$sourceWidth = 0 +$sourceHeight = 0 +if ($LASTEXITCODE -eq 0) { + $dimLine = $ffprobeDimOutput | Select-Object -First 1 + if ($dimLine) { + $dimText = ([string]$dimLine).Trim() + if ($dimText -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 video 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 +} + $ffprobeBitrateOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1 $ffprobeExit = $LASTEXITCODE @@ -384,7 +456,11 @@ if ($AudioPath -and $externalAudioCodec) { } Write-Host (' Frame range: {0} to {1} ({2} frames)' -f $StartFrame, $actualEndFrame, $frameCount) if ($scaleWidth -gt 0) { - Write-Host (' Size: {0}x{1}' -f $scaleWidth, $scaleHeight) + if ($LTX) { + Write-Host (' Size: {0}x{1} (LTX-2)' -f $scaleWidth, $scaleHeight) + } else { + Write-Host (' Size: {0}x{1}' -f $scaleWidth, $scaleHeight) + } } if ($FPS -gt 0) { Write-Host (' FPS: {0}' -f $FPS)