368 lines
15 KiB
PowerShell
368 lines
15 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Creates a turnaround image from a source of images using ffmpeg.
|
|
Source images are arranged horizontally in the output, or in a grid with -Rows / -Columns.
|
|
Images are never scaled: those smaller than the largest one are centred in their cell and
|
|
padded with -Background.
|
|
Supports PNG, JPEG, WEBP, BMP, AVIF and TIFF.
|
|
.PARAMETER Source
|
|
Path to the input directory containing image files with sequence numbers (e.g. Frame-00001.png). Can be absolute or relative to current directory.
|
|
The path can have a file pattern to specify the image files (e.g. Img*.jpg), or point at a single image file.
|
|
Image files are processed in numeric-aware (natural) name order, so Img2.jpg comes before Img10.jpg.
|
|
.PARAMETER Target
|
|
Path to the output turnaround image. Can be absolute or relative to current directory. .png extension is added if not provided.
|
|
A recognised image extension (.png, .jpg, .jpeg, .webp, .bmp, .avif, .tif, .tiff) selects the output format.
|
|
When omitted, a JPEG named after the last directory member of the source is written to the current directory:
|
|
- -Source is a directory: the last directory member of that directory's full path (e.g. C:\renders\CharacterA -> CharacterA.jpg).
|
|
- -Source is a file pattern or a single file: the last directory member of the current directory's full path.
|
|
Throws when that path is a drive root (e.g. C:\), since it has no directory member to name the file after.
|
|
.PARAMETER Rows
|
|
Number of rows to arrange the images in. Defaults to 1 (all images in a single horizontal row).
|
|
When only -Rows is given, the number of columns is computed as ceil(imageCount / Rows).
|
|
.PARAMETER Columns
|
|
Number of columns to arrange the images in. When only -Columns is given, the number of rows is
|
|
computed as ceil(imageCount / Columns). When both -Rows and -Columns are given, Rows * Columns
|
|
must be at least the number of images.
|
|
.PARAMETER Background
|
|
Colour used to fill the cells around images that are smaller than the largest image, and to fill
|
|
the unused cells of an incomplete last row. Defaults to 'white'. Accepts any ffmpeg colour, e.g.
|
|
a name (white, black, silver), 0xRRGGBB, or name@alpha ('white@0' for transparent).
|
|
.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). Defaults to 2.
|
|
- webp: inverted internally and passed to libwebp's -quality (0-100, higher is better) so the
|
|
"lower = better quality" semantic is consistent across formats. Defaults to 90.
|
|
- avif: passed to libaom-av1's -crf (0-63, lower is better). Defaults to 25.
|
|
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.
|
|
.DESCRIPTION
|
|
This script uses ffmpeg to create a turnaround image from a sequence of images.
|
|
Source images are arranged horizontally in the output, or in a grid with -Rows / -Columns.
|
|
Supports PNG, JPEG, WEBP, BMP, AVIF and TIFF.
|
|
Supports -WhatIf and -Confirm for safe execution.
|
|
|
|
Images are read from -Source (a directory, a file pattern such as Img*.jpg, or a single image file)
|
|
and ordered with a numeric-aware (natural) sort, so Img2.jpg comes before Img10.jpg.
|
|
|
|
Every image is placed in a cell as large as the largest source image and centred inside it, so
|
|
images that are not all the same size are padded with -Background rather than being scaled or
|
|
cropped: no source pixel is ever resized or lost. The output size is therefore
|
|
(cellWidth * columns) x (cellHeight * rows).
|
|
#>
|
|
[CmdletBinding(SupportsShouldProcess)]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Source,
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[string]$Target = $null,
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[int]$Rows = 0,
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[int]$Columns = 0,
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[string]$Background = 'white',
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[int]$Quality = -1,
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[int]$Compression = -1
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$supportedExts = @('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.avif', '.tif', '.tiff')
|
|
|
|
function Get-NaturalSortKey {
|
|
param([string]$Name)
|
|
|
|
# Zero-pad every digit run so an ordinal sort orders numbers numerically
|
|
# (Img2 -> Img000000000002, Img10 -> Img000000000010).
|
|
return (-join ($Name -split '(\d+)' | ForEach-Object {
|
|
if ($_ -match '^\d+$') { $_.PadLeft(12, '0') } else { $_.ToLowerInvariant() }
|
|
}))
|
|
}
|
|
|
|
function Get-ImageDimensions {
|
|
param([string]$Path)
|
|
|
|
$probeOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x -- $Path 2>&1
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Write-Error ('ffprobe failed for: {0}{1}{2}' -f $Path, [Environment]::NewLine, ($probeOutput -join [Environment]::NewLine))
|
|
exit 1
|
|
}
|
|
|
|
$dimLine = ([string]($probeOutput | Select-Object -First 1)).Trim()
|
|
if ($dimLine -notmatch '^(\d+)x(\d+)$') {
|
|
Write-Error ('Could not determine image dimensions for: {0}' -f $Path)
|
|
exit 1
|
|
}
|
|
|
|
return [pscustomobject]@{ Width = [int]$Matches[1]; Height = [int]$Matches[2] }
|
|
}
|
|
|
|
# --- Default -Target: '<last directory member>.jpg' in the current directory ---
|
|
# The name comes from the source directory when -Source is a directory, and from the
|
|
# current directory when -Source is a file pattern or a single file. Either way the file
|
|
# is written to the current directory.
|
|
if ([string]::IsNullOrWhiteSpace($Target)) {
|
|
if (Test-Path -LiteralPath $Source -PathType Container) {
|
|
$nameSourcePath = (Resolve-Path -LiteralPath $Source).Path
|
|
} else {
|
|
$nameSourcePath = (Get-Location).Path
|
|
}
|
|
|
|
$nameSourcePath = [System.IO.Path]::GetFullPath($nameSourcePath)
|
|
$trimmedPath = $nameSourcePath.TrimEnd('\', '/')
|
|
$nameLeaf = Split-Path -Path $trimmedPath -Leaf
|
|
|
|
# 'C:\' trims to 'C:', which has no directory member to name the file after.
|
|
if ([string]::IsNullOrWhiteSpace($nameLeaf) -or $trimmedPath -match '^[A-Za-z]:$') {
|
|
Write-Error ('Cannot derive a target file name from ''{0}'': the path is a drive root with no directory member. Specify -Target explicitly.' -f $nameSourcePath)
|
|
exit 1
|
|
}
|
|
|
|
$Target = Join-Path (Get-Location).Path ('{0}.jpg' -f $nameLeaf)
|
|
Write-Host ('No -Target specified; using {0}' -f $Target)
|
|
}
|
|
|
|
# --- Resolve -Target and the output format ---
|
|
$TargetPath = $Target
|
|
$targetExt = [System.IO.Path]::GetExtension($TargetPath).ToLowerInvariant()
|
|
if ($supportedExts -notcontains $targetExt) {
|
|
$TargetPath += '.png'
|
|
$targetExt = '.png'
|
|
}
|
|
|
|
switch ($targetExt) {
|
|
'.jpg' { $targetFormat = 'jpeg' }
|
|
'.jpeg' { $targetFormat = 'jpeg' }
|
|
'.tif' { $targetFormat = 'tiff' }
|
|
'.tiff' { $targetFormat = 'tiff' }
|
|
default { $targetFormat = $targetExt.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
|
|
}
|
|
|
|
# Absolute target path, used to keep the output out of its own input list when the
|
|
# turnaround image is written into the source directory (so re-runs stay idempotent).
|
|
if ([System.IO.Path]::IsPathRooted($TargetPath)) {
|
|
$targetFullPath = [System.IO.Path]::GetFullPath($TargetPath)
|
|
} else {
|
|
$targetFullPath = [System.IO.Path]::GetFullPath((Join-Path (Get-Location).Path $TargetPath))
|
|
}
|
|
|
|
# --- Resolve -Source into an ordered list of image files ---
|
|
# -Source may be a directory, a path containing wildcards (Img*.jpg) or a single image file.
|
|
if (Test-Path -LiteralPath $Source -PathType Container) {
|
|
$candidates = Get-ChildItem -LiteralPath $Source -File
|
|
} else {
|
|
$candidates = Get-ChildItem -Path $Source -File -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
if (-not $candidates) {
|
|
Write-Error ('No files found for source: {0}' -f $Source)
|
|
exit 1
|
|
}
|
|
|
|
$images = $candidates |
|
|
Where-Object { $supportedExts -contains $_.Extension.ToLowerInvariant() } |
|
|
Where-Object { $_.FullName -ine $targetFullPath } |
|
|
Sort-Object -Property @{ Expression = { Get-NaturalSortKey -Name $_.Name } }
|
|
|
|
if (-not $images) {
|
|
Write-Error ('No supported image files ({0}) found for source: {1}' -f ($supportedExts -join ', '), $Source)
|
|
exit 1
|
|
}
|
|
|
|
$imageCount = @($images).Count
|
|
|
|
# --- Grid layout ---
|
|
if ($Rows -lt 0 -or $Columns -lt 0) {
|
|
Write-Error '-Rows and -Columns must be positive values.'
|
|
exit 1
|
|
}
|
|
|
|
if ($Rows -gt 0 -and $Columns -gt 0) {
|
|
if (($Rows * $Columns) -lt $imageCount) {
|
|
Write-Error ('-Rows {0} x -Columns {1} = {2} cells is not enough for {3} images.' -f $Rows, $Columns, ($Rows * $Columns), $imageCount)
|
|
exit 1
|
|
}
|
|
} elseif ($Columns -gt 0) {
|
|
$Rows = [int][Math]::Ceiling($imageCount / [double]$Columns)
|
|
} elseif ($Rows -gt 0) {
|
|
$Columns = [int][Math]::Ceiling($imageCount / [double]$Rows)
|
|
} else {
|
|
# Documented default: a single horizontal row.
|
|
$Rows = 1
|
|
$Columns = $imageCount
|
|
}
|
|
|
|
# --- Probe every image; the cell is as large as the largest source image ---
|
|
$dimensions = @()
|
|
foreach ($image in $images) {
|
|
$dimensions += Get-ImageDimensions -Path $image.FullName
|
|
}
|
|
|
|
$cellWidth = ($dimensions | Measure-Object -Property Width -Maximum).Maximum
|
|
$cellHeight = ($dimensions | Measure-Object -Property Height -Maximum).Maximum
|
|
|
|
$mixedSizes = @($dimensions | Where-Object { $_.Width -ne $cellWidth -or $_.Height -ne $cellHeight }).Count
|
|
|
|
$outputWidth = $cellWidth * $Columns
|
|
$outputHeight = $cellHeight * $Rows
|
|
|
|
Write-Host ('Creating turnaround image from {0} image(s): {1}' -f $imageCount, $Source)
|
|
Write-Host (' Layout: {0} row(s) x {1} column(s)' -f $Rows, $Columns)
|
|
Write-Host (' Cell size: {0}x{1}' -f $cellWidth, $cellHeight)
|
|
if ($mixedSizes -gt 0) {
|
|
Write-Host (' Note: {0} image(s) smaller than the largest image; centred and padded with {1}.' -f $mixedSizes, $Background)
|
|
}
|
|
for ($i = 0; $i -lt $imageCount; $i++) {
|
|
Write-Host (' [{0}] {1} ({2}x{3})' -f ($i + 1), $images[$i].Name, $dimensions[$i].Width, $dimensions[$i].Height)
|
|
}
|
|
Write-Host (' Target format: {0}' -f $targetFormat)
|
|
Write-Host (' Output size: {0}x{1}' -f $outputWidth, $outputHeight)
|
|
if ($Quality -ge 0) {
|
|
if ($targetFormat -in @('jpeg', 'webp', 'avif')) {
|
|
Write-Host (' Quality: {0} (lower = better)' -f $Quality)
|
|
} else {
|
|
Write-Warning ('-Quality is ignored for the lossless {0} format.' -f $targetFormat)
|
|
}
|
|
}
|
|
if ($Compression -ge 0) {
|
|
if ($targetFormat -in @('png', 'webp')) {
|
|
Write-Host (' Compression: {0}' -f $Compression)
|
|
} else {
|
|
Write-Warning ('-Compression is ignored for the {0} format.' -f $targetFormat)
|
|
}
|
|
}
|
|
Write-Host (' Target: {0}' -f $TargetPath)
|
|
|
|
# --- Build the ffmpeg filter graph ---
|
|
# Every input is normalised to rgba (hstack/vstack require matching pixel formats across
|
|
# inputs) and centred in a cellWidth x cellHeight box. Rows are hstacked, then vstacked.
|
|
# A short last row is centred horizontally inside the full row width.
|
|
$filters = @()
|
|
for ($i = 0; $i -lt $imageCount; $i++) {
|
|
$filters += '[{0}:v]format=rgba,pad={1}:{2}:(ow-iw)/2:(oh-ih)/2:{3}[v{0}]' -f $i, $cellWidth, $cellHeight, $Background
|
|
}
|
|
|
|
$rowLabels = @()
|
|
for ($r = 0; $r -lt $Rows; $r++) {
|
|
$first = $r * $Columns
|
|
$last = [Math]::Min($first + $Columns, $imageCount) - 1
|
|
$inRow = $last - $first + 1
|
|
|
|
$rowInputs = -join (($first..$last) | ForEach-Object { '[v{0}]' -f $_ })
|
|
$rowLabel = 'r{0}' -f $r
|
|
|
|
if ($inRow -gt 1) {
|
|
$filters += '{0}hstack=inputs={1}[{2}]' -f $rowInputs, $inRow, $rowLabel
|
|
} else {
|
|
$filters += '{0}null[{1}]' -f $rowInputs, $rowLabel
|
|
}
|
|
|
|
if ($inRow -lt $Columns) {
|
|
# Incomplete last row: centre it in the full output width.
|
|
$filters += '[{0}]pad={1}:{2}:(ow-iw)/2:0:{3}[{0}p]' -f $rowLabel, $outputWidth, $cellHeight, $Background
|
|
$rowLabel = '{0}p' -f $rowLabel
|
|
}
|
|
|
|
$rowLabels += '[{0}]' -f $rowLabel
|
|
}
|
|
|
|
if ($Rows -gt 1) {
|
|
$filters += '{0}vstack=inputs={1}[outv]' -f (-join $rowLabels), $Rows
|
|
} else {
|
|
$filters += '{0}null[outv]' -f $rowLabels[0]
|
|
}
|
|
|
|
$filterComplex = $filters -join ';'
|
|
|
|
# --- Per-format encoder arguments (same mapping and defaults as Clone-Image.ps1) ---
|
|
# -Quality is always "lower = better" from the caller's point of view; the per-format
|
|
# translation (including inverting it for libwebp) 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') }
|
|
}
|
|
|
|
$ffmpegArgs = @('-y')
|
|
foreach ($image in $images) {
|
|
$ffmpegArgs += @('-i', $image.FullName)
|
|
}
|
|
$ffmpegArgs += @('-filter_complex', $filterComplex, '-map', '[outv]')
|
|
$ffmpegArgs += $encoderArgs
|
|
$ffmpegArgs += @('-frames:v', '1', '-map_metadata', '-1', '--', $TargetPath)
|
|
|
|
if ($PSCmdlet.ShouldProcess($TargetPath, 'Create turnaround image')) {
|
|
$PSNativeCommandUseErrorActionPreference = $false
|
|
|
|
$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 with exit code: {0}' -f $ffmpegExit)
|
|
exit 1
|
|
}
|
|
|
|
$outInfo = Get-ImageDimensions -Path $TargetPath
|
|
Write-Host ('Turnaround image created: {0} ({1}x{2})' -f $TargetPath, $outInfo.Width, $outInfo.Height) -ForegroundColor Green
|
|
} else {
|
|
$formattedArgs = $ffmpegArgs | ForEach-Object { if ($_ -match '\s') { '"{0}"' -f $_ } else { $_ } }
|
|
Write-Host ('WhatIf: Would create turnaround image with ffmpeg {0}' -f ($formattedArgs -join ' '))
|
|
}
|