Files

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 ' '))
}