Files
Video-Processing-Scripts/Clone-Image.ps1
T

493 lines
20 KiB
PowerShell

<#
.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 (<Model>.bin and <Model>.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)
$upscaylArgs = @('-i', $SourcePath, '-o', $upscaledTempPath, '-s', $Upscale.ToString(), '-n', $Model, '-m', $modelsDir, '-g', $gpuId)
$upscaylCmdLine = '"{0}" {1}' -f $upscaylExePath, (($upscaylArgs | ForEach-Object {
if ($_ -match '\s') { '"{0}"' -f $_ } else { $_ }
}) -join ' ')
Write-Host (' Upscayl command: {0}' -f $upscaylCmdLine)
if ($PSCmdlet.ShouldProcess($SourcePath, ('Upscale x{0} with upscayl-bin into temp file' -f $Upscale))) {
$upscaylOutput = & $upscaylExePath @upscaylArgs 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
}