diff --git a/Create-Turnaround.cmd b/Create-Turnaround.cmd new file mode 100644 index 0000000..b19a7a4 --- /dev/null +++ b/Create-Turnaround.cmd @@ -0,0 +1,2 @@ +@echo off +pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Create-Turnaround.ps1" %* diff --git a/Create-Turnaround.ps1 b/Create-Turnaround.ps1 new file mode 100644 index 0000000..e7c6421 --- /dev/null +++ b/Create-Turnaround.ps1 @@ -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 ' ')) +} diff --git a/README.md b/README.md index 681d926..e537fbb 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ To install rife-ncnn-vulkan on Windows, download it from [rife-ncnn-vulkan](http | Compare-Videos.ps1 | Combine 2 to 4 video files into a single 1920x1080 side-by-side or grid comparison video | | Create-FullSubtitle.ps1 | Creates a full subtitle file from a video file and two SRT subtitle files by combining them with adjusted timing and sequential numbering | | Create-ShortClip.ps1 | Create short clips from videos with cropping and overlay options | +| Create-Turnaround.ps1 | Combine a sequence of images into a single turnaround (character reference sheet) image, in a row or a grid | | Crop-Clips.ps1 | Batch crop videos generated by LTX-2, Kling and Wan from their non-standard resolutions to standard ones (also scales any square video to 1080x1080) | | Get-Frames.ps1 | Extract a range of frames to PNG images | | Get-FramesCount.ps1 | Get total frame count of a video |