Add -LTX parameter to select closest LTX-2 canonical resolution for specified aspect ratio when using -Size with HD/1080p/720p/480p presets

This commit is contained in:
2026-06-10 11:32:15 -04:00
parent 46335f2cb1
commit 1e58558308
3 changed files with 398 additions and 2 deletions
+2
View File
@@ -0,0 +1,2 @@
@echo off
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Clone-Image.ps1" %*
+318
View File
@@ -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)
}
+78 -2
View File
@@ -41,6 +41,14 @@
Honours all other parameters (StartFrame/EndFrame range, Size, FPS, CRF, container choice, etc.). 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. 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. 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 .DESCRIPTION
This script uses ffmpeg to clone (copy) a video file with optional frame range and audio exclusion. 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. When Sequence is provided, it extracts a specific frame to PNG and then clones the video accordingly.
@@ -82,7 +90,10 @@ param(
[switch]$Metadata, [switch]$Metadata,
[Parameter(Mandatory = $false)] [Parameter(Mandatory = $false)]
[switch]$Reverse [switch]$Reverse,
[Parameter(Mandatory = $false)]
[switch]$LTX
) )
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
@@ -261,6 +272,67 @@ if (-not $totalFrames -or $totalFrames -notmatch '^\d+$') {
$totalFramesInt = [int]$totalFrames $totalFramesInt = [int]$totalFrames
$maxFrameIndex = $totalFramesInt - 1 $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 $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 $ffprobeExit = $LASTEXITCODE
@@ -384,7 +456,11 @@ if ($AudioPath -and $externalAudioCodec) {
} }
Write-Host (' Frame range: {0} to {1} ({2} frames)' -f $StartFrame, $actualEndFrame, $frameCount) Write-Host (' Frame range: {0} to {1} ({2} frames)' -f $StartFrame, $actualEndFrame, $frameCount)
if ($scaleWidth -gt 0) { 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) { if ($FPS -gt 0) {
Write-Host (' FPS: {0}' -f $FPS) Write-Host (' FPS: {0}' -f $FPS)