Files
Video-Processing-Scripts/Create-Turnaround.ps1
T

339 lines
13 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.
.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 ' '))
}