Add Create-Turnaround.ps1 and Create-Turnaround.cmd wrapper scripts for combining image sequences into turnaround/character reference sheet images arranged in a row or grid, supporting PNG/JPEG/WEBP/BMP/AVIF/TIFF formats with natural numeric sorting, automatic cell sizing based on largest image with centered padding for smaller images using -Background color, configurable -Rows and -Columns layout with incomplete last row centering, format-specific -Quality and -Compression parameters matching Clone
This commit is contained in:
1 parent
ae7880af95
commit
186daada82
3 files changed
+341
No files matched your search
@@ -0,0 +1,338 @@
|
||||
<#
|
||||
.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.
|
||||
.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 = $true)]
|
||||
[string]$Target,
|
||||
|
||||
[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] }
|
||||
}
|
||||
|
||||
# --- 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 ' '))
|
||||
}
|
||||
Reference in new issue
Block a user