612 lines
29 KiB
PowerShell
612 lines
29 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Creates side-by-side or grid comparison videos with automatic scaling and padding to preserve aspect ratios.
|
|
|
|
.DESCRIPTION
|
|
Combines 2 to 4 video files into a single comparison video using ffmpeg.
|
|
By default, creates 1920x1080 (1K HD) output. Use -In4K for 3840x2160 (4K UHD) output.
|
|
Each source video is scaled to fit its cell without stretching, with black letterbox/pillarbox
|
|
padding applied as needed to preserve aspect ratios.
|
|
|
|
The script detects the orientation of S1 (landscape or portrait) and applies appropriate scaling:
|
|
|
|
Landscape mode (width > height) - 1K HD:
|
|
- 2 videos: Side-by-side (960x1080 each)
|
|
- 3 videos: 2x2 grid with S3 centered on bottom row (960x540 each)
|
|
- 4 videos: 2x2 grid (960x540 each)
|
|
|
|
Landscape mode (width > height) - 4K UHD:
|
|
- 2 videos: Side-by-side (1920x2160 each)
|
|
- 3 videos: 2x2 grid with S3 centered on bottom row (1920x1080 each)
|
|
- 4 videos: 2x2 grid (1920x1080 each)
|
|
|
|
Portrait mode (height > width, e.g., 1080x1920) - 1K HD:
|
|
- 2 videos: Side-by-side (608x1080 each, centered with padding to 1920x1080; or 1216x1080 with -Clip)
|
|
- 3 videos: Side-by-side (608x1080 each, centered with padding to 1920x1080; or 1824x1080 with -Clip)
|
|
- 4 videos: Side-by-side (480x854 each, centered with padding to 1920x1080; or 1920x854 with -Clip)
|
|
|
|
Portrait mode (height > width, e.g., 1080x1920) - 4K UHD:
|
|
- 2 videos: Side-by-side (1216x2160 each, centered with padding to 3840x2160; or 2432x2160 with -Clip)
|
|
- 3 videos: Side-by-side (1216x2160 each, centered with padding to 3840x2160; or 3648x2160 with -Clip)
|
|
- 4 videos: Side-by-side (960x1708 each, centered with padding to 3840x2160; or 3840x1708 with -Clip)
|
|
|
|
With -Sideways, all videos are arranged in a single horizontal row. Adding -Clip removes
|
|
black padding: for landscape, cells are clipped to content height; for portrait, the
|
|
outer centering pad is omitted.
|
|
|
|
Sideways mode - Landscape (width > height) - 1K HD:
|
|
- 2 videos: Cell 960x1080, content 960x540; total 1920x1080 (or 1920x540 with -Clip)
|
|
- 3 videos: Cell 640x1080, content 640x360; total 1920x1080 (or 1920x360 with -Clip)
|
|
- 4 videos: Cell 480x1080, content 480x270; total 1920x1080 (or 1920x270 with -Clip)
|
|
|
|
Sideways mode - Landscape (width > height) - 4K UHD:
|
|
- 2 videos: Cell 1920x2160, content 1920x1080; total 3840x2160 (or 3840x1080 with -Clip)
|
|
- 3 videos: Cell 1280x2160, content 1280x720; total 3840x2160 (or 3840x720 with -Clip)
|
|
- 4 videos: Cell 960x2160, content 960x540; total 3840x2160 (or 3840x540 with -Clip)
|
|
|
|
Sideways mode - Portrait (height > width) - 1K HD:
|
|
- 2 videos: Cell 608x1080; output 1920x1080 (or 1216x1080 with -Clip)
|
|
- 3 videos: Cell 608x1080; output 1920x1080 (or 1824x1080 with -Clip)
|
|
- 4 videos: Cell 480x854; output 1920x1080 (or 1920x854 with -Clip)
|
|
|
|
Sideways mode - Portrait (height > width) - 4K UHD:
|
|
- 2 videos: Cell 1216x2160; output 3840x2160 (or 2432x2160 with -Clip)
|
|
- 3 videos: Cell 1216x2160; output 3840x2160 (or 3648x2160 with -Clip)
|
|
- 4 videos: Cell 960x1706; output 3840x2160 (or 3840x1706 with -Clip)
|
|
|
|
Requires ffmpeg and ffprobe in system PATH. Supports -WhatIf for dry-run testing.
|
|
|
|
.PARAMETER S1
|
|
Path to source video 1 (required).
|
|
|
|
.PARAMETER S2
|
|
Path to source video 2 (required).
|
|
|
|
.PARAMETER S3
|
|
Path to source video 3 (optional).
|
|
|
|
.PARAMETER S4
|
|
Path to source video 4 (optional).
|
|
|
|
.PARAMETER Output
|
|
Path to the output video file (required).
|
|
|
|
.PARAMETER NoAudio
|
|
Exclude audio from output. By default, audio is copied from S1.
|
|
|
|
.PARAMETER In4K
|
|
Generate comparison video in 4K UHD (3840x2160) resolution instead of 1K HD (1920x1080).
|
|
|
|
.PARAMETER Clip
|
|
Reduces the output height to match the actual scaled height of the sources, removing
|
|
the top/bottom black letterbox padding. For landscape, only applies when exactly 2
|
|
source videos are provided; ignored otherwise. For portrait, applies when 2-4 source
|
|
videos are provided (all sources must be portrait); for 2-3 videos removes the outer
|
|
side padding, for 4 videos removes the outer top/bottom padding.
|
|
|
|
Behavior depends on the orientations of the two sources:
|
|
|
|
- Both same orientation (both landscape or both portrait): the output height is set
|
|
to the larger of the two sources' scaled heights inside their half-width cells.
|
|
Example: two 1280x720 sources -> 1920x540 (1K) or 3840x1080 (4K).
|
|
|
|
- Mixed orientations (one landscape, one portrait), and the landscape source is
|
|
smaller than the target resolution: the landscape video occupies its native pixel
|
|
width at 1K HD (or 2x in 4K), and the portrait video is scaled to match the
|
|
landscape's height and centered in the remaining width with black pillar boxes.
|
|
Example: 1280x720 landscape + 720x1280 portrait at 1K HD -> 1920x720 (landscape
|
|
occupies 1280x720 on its S1/S2 side, portrait fits in the remaining 640x720).
|
|
At 4K the same pair becomes 3840x1440 (landscape 2560x1440, portrait cell 1280x1440).
|
|
|
|
.PARAMETER Sideways
|
|
Arranges all videos in a single horizontal row. For landscape mode, replaces the default
|
|
2x2 grid layout (3 and 4 videos) with a single row. For portrait mode, the layout is
|
|
the same as the default (already side-by-side). Can be combined with -Clip to remove
|
|
black padding: for landscape, the output height equals the natural content height; for
|
|
portrait, the outer centering pad is omitted.
|
|
|
|
.EXAMPLE
|
|
.\Compare-Videos.ps1 -S1 original.mp4 -S2 enhanced.mp4 -Output comparison.mp4
|
|
|
|
Compare two videos side-by-side.
|
|
|
|
.EXAMPLE
|
|
.\Compare-Videos.ps1 -S1 version1.mp4 -S2 version2.mp4 -S3 version3.mp4 -Output comparison.mp4
|
|
|
|
Compare three videos.
|
|
|
|
.EXAMPLE
|
|
.\Compare-Videos.ps1 -S1 a.mp4 -S2 b.mp4 -S3 c.mp4 -S4 d.mp4 -Output grid.mp4
|
|
|
|
Compare four videos in a 2x2 grid.
|
|
|
|
.EXAMPLE
|
|
.\Compare-Videos.ps1 -S1 video1.mp4 -S2 video2.mp4 -Output comparison.mp4 -NoAudio
|
|
|
|
Compare videos without including audio in the output.
|
|
|
|
.EXAMPLE
|
|
.\Compare-Videos.ps1 -S1 original.mp4 -S2 enhanced.mp4 -Output comparison_4k.mp4 -In4K
|
|
|
|
Compare two videos in 4K UHD (3840x2160) resolution.
|
|
|
|
.EXAMPLE
|
|
.\Compare-Videos.ps1 -S1 a_720p.mp4 -S2 b_720p.mp4 -Output side-by-side.mp4 -Clip
|
|
|
|
Compare two 720p (1280x720) videos with no top/bottom letterboxing: output is 1920x540.
|
|
|
|
.EXAMPLE
|
|
.\Compare-Videos.ps1 -S1 a.mp4 -S2 b.mp4 -S3 c.mp4 -Output sideways.mp4 -Sideways
|
|
|
|
Compare three landscape videos in a single row (three 640x1080 cells, total 1920x1080).
|
|
|
|
.EXAMPLE
|
|
.\Compare-Videos.ps1 -S1 a.mp4 -S2 b.mp4 -S3 c.mp4 -S4 d.mp4 -Output sideways.mp4 -Sideways
|
|
|
|
Compare four landscape videos in a single row (four 480x1080 cells, total 1920x1080).
|
|
|
|
.EXAMPLE
|
|
.\Compare-Videos.ps1 -S1 a.mp4 -S2 b.mp4 -S3 c.mp4 -Output sideways_clip.mp4 -Sideways -Clip
|
|
|
|
Three landscape videos in a single row, clipped to content height (three 640x360 cells, total 1920x360).
|
|
|
|
.EXAMPLE
|
|
.\Compare-Videos.ps1 -S1 portrait1.mp4 -S2 portrait2.mp4 -Output portrait_clip.mp4 -Clip
|
|
|
|
Two portrait videos without outer side padding: output is 1216x1080 (1K) or 2432x2160 (4K with -In4K).
|
|
|
|
.EXAMPLE
|
|
.\Compare-Videos.ps1 -S1 p1.mp4 -S2 p2.mp4 -S3 p3.mp4 -Output portrait3_clip.mp4 -Clip
|
|
|
|
Three portrait videos without outer side padding: output is 1824x1080 (1K). All sources must be portrait; if any is not, falls back to padded 1920x1080 layout.
|
|
|
|
.EXAMPLE
|
|
.\Compare-Videos.ps1 -S1 p1.mp4 -S2 p2.mp4 -S3 p3.mp4 -S4 p4.mp4 -Output portrait4_clip.mp4 -Clip
|
|
|
|
Four portrait videos without outer top/bottom padding: output is 1920x854 (1K). All sources must be portrait; if any is not, falls back to padded 1920x1080 layout.
|
|
#>
|
|
[CmdletBinding(SupportsShouldProcess)]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$S1,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$S2,
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[string]$S3,
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[string]$S4,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Output,
|
|
|
|
[switch]$NoAudio,
|
|
|
|
[switch]$In4K,
|
|
|
|
[switch]$Clip,
|
|
|
|
[switch]$Sideways
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
function Test-VideoExists {
|
|
param([string]$Path, [string]$Name)
|
|
|
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
|
Write-Error "Source video $Name does not exist: $Path"
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
Test-VideoExists -Path $S1 -Name "S1"
|
|
Test-VideoExists -Path $S2 -Name "S2"
|
|
|
|
if ($S3) {
|
|
Test-VideoExists -Path $S3 -Name "S3"
|
|
}
|
|
|
|
if ($S4) {
|
|
Test-VideoExists -Path $S4 -Name "S4"
|
|
}
|
|
|
|
$outputDir = Split-Path -Path $Output -Parent
|
|
if ($outputDir -and -not (Test-Path -LiteralPath $outputDir -PathType Container)) {
|
|
Write-Error "Output directory does not exist: $outputDir"
|
|
exit 1
|
|
}
|
|
|
|
$videoCount = 2
|
|
if ($S4) { $videoCount = 4 }
|
|
elseif ($S3) { $videoCount = 3 }
|
|
|
|
# --- Detect orientation of S1 ---
|
|
|
|
Write-Host "Detecting orientation of S1..." -ForegroundColor Cyan
|
|
$probeOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 $S1 2>&1
|
|
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Write-Error "Failed to probe video dimensions for S1: $probeOutput"
|
|
exit 1
|
|
}
|
|
|
|
$dimensions = $probeOutput -split ','
|
|
$width = [int]$dimensions[0]
|
|
$height = [int]$dimensions[1]
|
|
|
|
$isPortrait = $height -gt $width
|
|
|
|
# Set resolution based on -In4K switch
|
|
if ($In4K) {
|
|
$outputWidth = 3840
|
|
$outputHeight = 2160
|
|
$resolutionLabel = "4K UHD"
|
|
} else {
|
|
$outputWidth = 1920
|
|
$outputHeight = 1080
|
|
$resolutionLabel = "1K HD"
|
|
}
|
|
|
|
if ($isPortrait) {
|
|
Write-Host "Portrait mode detected (${width}x${height}) - Output: $resolutionLabel" -ForegroundColor Green
|
|
} else {
|
|
Write-Host "Landscape mode detected (${width}x${height}) - Output: $resolutionLabel" -ForegroundColor Green
|
|
}
|
|
|
|
if ($Clip -and -not $Sideways) {
|
|
$clipAllowed = ($isPortrait -and $videoCount -le 4) -or (-not $isPortrait -and $videoCount -eq 2)
|
|
if (-not $clipAllowed) {
|
|
Write-Warning "-Clip without -Sideways is only supported with 2 landscape videos, or 2-4 portrait videos; ignoring."
|
|
$Clip = $false
|
|
}
|
|
}
|
|
|
|
# --- Clip mode: shrink output height to the actual scaled height of the sources ---
|
|
$clipMixedLandscape = $false
|
|
$clipMixedFilterComplex = $null
|
|
if ($Clip -and -not $Sideways) {
|
|
Write-Host "Probing S2 dimensions for -Clip..." -ForegroundColor Cyan
|
|
$probeOutput2 = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 $S2 2>&1
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Write-Error "Failed to probe video dimensions for S2: $probeOutput2"
|
|
exit 1
|
|
}
|
|
$dim2 = $probeOutput2 -split ','
|
|
$w2 = [int]$dim2[0]
|
|
$h2 = [int]$dim2[1]
|
|
|
|
$s1IsLandscape = $width -gt $height
|
|
$s2IsLandscape = $w2 -gt $h2
|
|
$isMixedOrientation = ($s1IsLandscape -xor $s2IsLandscape)
|
|
|
|
if ($isMixedOrientation) {
|
|
# One source is landscape, the other is portrait. Try the mixed-orientation
|
|
# layout: the landscape video keeps its native pixel size at 1K HD (or 2x in 4K),
|
|
# and the portrait video is scaled to match the landscape's height and centered
|
|
# in the remaining width with black pillar-box bars.
|
|
$scaleFactor = $outputWidth / 1920.0 # 1.0 at 1K HD, 2.0 at 4K UHD
|
|
|
|
if ($s1IsLandscape) {
|
|
$landscapeW = $width; $landscapeH = $height
|
|
} else {
|
|
$landscapeW = $w2; $landscapeH = $h2
|
|
}
|
|
|
|
$landscapeOutW = [int][Math]::Floor($landscapeW * $scaleFactor)
|
|
$landscapeOutH = [int][Math]::Floor($landscapeH * $scaleFactor)
|
|
if ($landscapeOutW % 2) { $landscapeOutW++ }
|
|
if ($landscapeOutH % 2) { $landscapeOutH++ }
|
|
|
|
if ($landscapeOutW -lt $outputWidth -and $landscapeOutH -le $outputHeight) {
|
|
$portraitCellW = $outputWidth - $landscapeOutW
|
|
$outputHeight = $landscapeOutH
|
|
|
|
Write-Host ("Clip mode (mixed orientation): landscape {0}x{1} + portrait {2}x{1} -> {3}x{1}" -f $landscapeOutW, $landscapeOutH, $portraitCellW, $outputWidth) -ForegroundColor Yellow
|
|
|
|
# Preserve S1=left, S2=right ordering. Each input is scaled into its cell
|
|
# with aspect-ratio preserved, then padded with black to exactly fill the cell.
|
|
if ($s1IsLandscape) {
|
|
$leftW = $landscapeOutW; $rightW = $portraitCellW
|
|
} else {
|
|
$leftW = $portraitCellW; $rightW = $landscapeOutW
|
|
}
|
|
$clipMixedFilterComplex = "[0:v]scale=${leftW}:${landscapeOutH}:force_original_aspect_ratio=decrease,pad=${leftW}:${landscapeOutH}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
|
|
"[1:v]scale=${rightW}:${landscapeOutH}:force_original_aspect_ratio=decrease,pad=${rightW}:${landscapeOutH}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
|
|
"[v0][v1]hstack=inputs=2[outv]"
|
|
$clipMixedLandscape = $true
|
|
} else {
|
|
Write-Host "Clip mode: mixed orientations detected, but the landscape source already fills the target width; using standard clip layout." -ForegroundColor Yellow
|
|
}
|
|
}
|
|
|
|
if (-not $clipMixedLandscape) {
|
|
# Standard -Clip behaviour: both sources share an orientation (or mixed fell back).
|
|
# Compute the largest natural scaled height across both sources and reduce the
|
|
# output height to that value.
|
|
|
|
# Per-source cell width matches the 2-video branches below.
|
|
if ($isPortrait) {
|
|
$clipCellWidth = [int]($outputWidth * 0.6333 / 2)
|
|
} else {
|
|
$clipCellWidth = [int]($outputWidth / 2)
|
|
}
|
|
|
|
# Height each source would naturally scale to inside its cell when fitted by width
|
|
# (force_original_aspect_ratio=decrease). Take the larger of the two so neither
|
|
# source loses content; cap at the original output height.
|
|
$h1Scaled = [int][Math]::Floor($clipCellWidth * $height / $width)
|
|
$h2Scaled = [int][Math]::Floor($clipCellWidth * $h2 / $w2)
|
|
$clippedHeight = [Math]::Max($h1Scaled, $h2Scaled)
|
|
if ($clippedHeight % 2) { $clippedHeight++ } # libx264/yuv420p requires even dimensions
|
|
|
|
# 320p and 384p sources have non-standard widths that cause off-by-one rounding
|
|
# below a true 16:9 height. Force the exact 16:9 cell height for these sources.
|
|
if (-not $isPortrait -and ($height -eq 320 -or $height -eq 384 -or $h2 -eq 320 -or $h2 -eq 384)) {
|
|
$forced16x9Height = [int]($clipCellWidth * 9 / 16)
|
|
if ($forced16x9Height % 2) { $forced16x9Height++ }
|
|
$clippedHeight = $forced16x9Height
|
|
}
|
|
|
|
if ($clippedHeight -gt $outputHeight) { $clippedHeight = $outputHeight }
|
|
|
|
if ($clippedHeight -lt $outputHeight) {
|
|
Write-Host ("Clip mode: output height reduced from {0} to {1} ({2}x{1})" -f $outputHeight, $clippedHeight, $outputWidth) -ForegroundColor Yellow
|
|
$outputHeight = $clippedHeight
|
|
} else {
|
|
Write-Host "Clip mode: sources already fill the full output height; no clipping applied." -ForegroundColor Yellow
|
|
}
|
|
}
|
|
}
|
|
|
|
$filterComplex = ""
|
|
$inputArgs = @("-i", $S1, "-i", $S2)
|
|
|
|
if ($Sideways) {
|
|
Write-Host "Sideways mode: arranging $videoCount videos in a single row." -ForegroundColor Cyan
|
|
|
|
if ($isPortrait) {
|
|
if ($videoCount -le 3) {
|
|
$swCellW = [int]($outputWidth * 0.6333 / 2) # ~608 for 1K, ~1216 for 4K
|
|
$swCellH = $outputHeight
|
|
} else {
|
|
$swCellW = [int]($outputWidth / 4) # 480 for 1K, 960 for 4K
|
|
$swCellH = [int]($outputHeight * 0.79) # ~854 for 1K, ~1706 for 4K
|
|
if ($swCellH % 2) { $swCellH++ }
|
|
}
|
|
} else {
|
|
$swCellW = [int]($outputWidth / $videoCount) # 960/640/480 for 1K, 1920/1280/960 for 4K
|
|
$swCellH = $outputHeight
|
|
}
|
|
|
|
if ($Clip -and -not $isPortrait) {
|
|
$swCellH = [int]($swCellW * $height / $width)
|
|
if ($swCellH % 2) { $swCellH++ }
|
|
# 320p and 384p sources have non-standard widths that cause off-by-one rounding
|
|
# below a true 16:9 height. Force the exact 16:9 cell height for these sources.
|
|
if ($height -eq 320 -or $height -eq 384 -or $h2 -eq 320 -or $h2 -eq 384) {
|
|
$forced16x9Height = [int]($swCellW * 9 / 16)
|
|
if ($forced16x9Height % 2) { $forced16x9Height++ }
|
|
$swCellH = $forced16x9Height
|
|
}
|
|
if ($swCellH -gt $outputHeight) { $swCellH = $outputHeight }
|
|
Write-Host ("Sideways clip: cell height reduced to {0} ({1}x{0} per cell)" -f $swCellH, $swCellW) -ForegroundColor Yellow
|
|
}
|
|
|
|
$swFilter = "scale=${swCellW}:${swCellH}:force_original_aspect_ratio=decrease,pad=${swCellW}:${swCellH}:(ow-iw)/2:(oh-ih)/2:black"
|
|
|
|
if ($Clip) {
|
|
$swHstackEnd = "[outv]"
|
|
} else {
|
|
$swHstackEnd = "[stacked];[stacked]pad=${outputWidth}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[outv]"
|
|
}
|
|
|
|
switch ($videoCount) {
|
|
2 {
|
|
$filterComplex = "[0:v]${swFilter}[v0];" +
|
|
"[1:v]${swFilter}[v1];" +
|
|
"[v0][v1]hstack=inputs=2${swHstackEnd}"
|
|
}
|
|
3 {
|
|
$inputArgs += @("-i", $S3)
|
|
$filterComplex = "[0:v]${swFilter}[v0];" +
|
|
"[1:v]${swFilter}[v1];" +
|
|
"[2:v]${swFilter}[v2];" +
|
|
"[v0][v1][v2]hstack=inputs=3${swHstackEnd}"
|
|
}
|
|
4 {
|
|
$inputArgs += @("-i", $S3, "-i", $S4)
|
|
$filterComplex = "[0:v]${swFilter}[v0];" +
|
|
"[1:v]${swFilter}[v1];" +
|
|
"[2:v]${swFilter}[v2];" +
|
|
"[3:v]${swFilter}[v3];" +
|
|
"[v0][v1][v2][v3]hstack=inputs=4${swHstackEnd}"
|
|
}
|
|
}
|
|
} elseif ($clipMixedLandscape) {
|
|
# Mixed-orientation -Clip layout was prepared above; use it as-is.
|
|
$filterComplex = $clipMixedFilterComplex
|
|
} elseif ($isPortrait) {
|
|
# Portrait mode scaling - calculate cell dimensions based on output resolution
|
|
$cellWidth2 = [int]($outputWidth * 0.6333 / 2) # ~608 for 1K, ~1216 for 4K
|
|
$cellWidth3 = [int]($outputWidth * 0.6333 / 2) # ~608 for 1K, ~1216 for 4K
|
|
$cellWidth4 = [int]($outputWidth / 4) # 480 for 1K, 960 for 4K
|
|
$cellHeight4 = [int][Math]::Ceiling($cellWidth4 * 16.0 / 9.0) # 9:16 ratio: 854 for 1K, 1708 for 4K
|
|
if ($cellHeight4 % 2) { $cellHeight4++ }
|
|
|
|
switch ($videoCount) {
|
|
2 {
|
|
# 2 videos side-by-side, centered (or unpadded with -Clip)
|
|
if ($Clip) {
|
|
$clippedWidth = $cellWidth2 * 2
|
|
Write-Host ("Clip mode (portrait): output width reduced from {0} to {1} ({1}x{2})" -f $outputWidth, $clippedWidth, $outputHeight) -ForegroundColor Yellow
|
|
$filterComplex = "[0:v]scale=${cellWidth2}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth2}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
|
|
"[1:v]scale=${cellWidth2}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth2}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
|
|
"[v0][v1]hstack=inputs=2[outv]"
|
|
} else {
|
|
$filterComplex = "[0:v]scale=${cellWidth2}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth2}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
|
|
"[1:v]scale=${cellWidth2}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth2}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
|
|
"[v0][v1]hstack=inputs=2[stacked];" +
|
|
"[stacked]pad=${outputWidth}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[outv]"
|
|
}
|
|
}
|
|
3 {
|
|
# 3 videos side-by-side, centered (or unpadded with -Clip if all sources are portrait)
|
|
$inputArgs += @("-i", $S3)
|
|
$applyPortraitClip = $false
|
|
if ($Clip) {
|
|
$probeOutput3 = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 $S3 2>&1
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Write-Error "Failed to probe video dimensions for S3: $probeOutput3"
|
|
exit 1
|
|
}
|
|
$dim3 = $probeOutput3 -split ','
|
|
$w3 = [int]$dim3[0]; $h3 = [int]$dim3[1]
|
|
if (($h2 -gt $w2) -and ($h3 -gt $w3)) {
|
|
$applyPortraitClip = $true
|
|
} else {
|
|
Write-Warning "-Clip (portrait 3-video): not all sources are portrait; falling back to padded layout."
|
|
}
|
|
}
|
|
if ($applyPortraitClip) {
|
|
$clippedWidth = $cellWidth3 * 3
|
|
Write-Host ("Clip mode (portrait): output width reduced from {0} to {1} ({1}x{2})" -f $outputWidth, $clippedWidth, $outputHeight) -ForegroundColor Yellow
|
|
$filterComplex = "[0:v]scale=${cellWidth3}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth3}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
|
|
"[1:v]scale=${cellWidth3}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth3}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
|
|
"[2:v]scale=${cellWidth3}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth3}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v2];" +
|
|
"[v0][v1][v2]hstack=inputs=3[outv]"
|
|
} else {
|
|
$filterComplex = "[0:v]scale=${cellWidth3}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth3}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
|
|
"[1:v]scale=${cellWidth3}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth3}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
|
|
"[2:v]scale=${cellWidth3}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth3}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v2];" +
|
|
"[v0][v1][v2]hstack=inputs=3[stacked];" +
|
|
"[stacked]pad=${outputWidth}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[outv]"
|
|
}
|
|
}
|
|
4 {
|
|
# 4 videos side-by-side, centered (or unpadded with -Clip if all sources are portrait)
|
|
$inputArgs += @("-i", $S3, "-i", $S4)
|
|
$applyPortraitClip4 = $false
|
|
if ($Clip) {
|
|
$probeOutput3 = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 $S3 2>&1
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Write-Error "Failed to probe video dimensions for S3: $probeOutput3"
|
|
exit 1
|
|
}
|
|
$dim3 = $probeOutput3 -split ','
|
|
$w3 = [int]$dim3[0]; $h3 = [int]$dim3[1]
|
|
$probeOutput4 = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 $S4 2>&1
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Write-Error "Failed to probe video dimensions for S4: $probeOutput4"
|
|
exit 1
|
|
}
|
|
$dim4 = $probeOutput4 -split ','
|
|
$w4 = [int]$dim4[0]; $h4 = [int]$dim4[1]
|
|
if (($h2 -gt $w2) -and ($h3 -gt $w3) -and ($h4 -gt $w4)) {
|
|
$applyPortraitClip4 = $true
|
|
} else {
|
|
Write-Warning "-Clip (portrait 4-video): not all sources are portrait; falling back to padded layout."
|
|
}
|
|
}
|
|
if ($applyPortraitClip4) {
|
|
Write-Host ("Clip mode (portrait): output height reduced from {0} to {1} ({2}x{1})" -f $outputHeight, $cellHeight4, $outputWidth) -ForegroundColor Yellow
|
|
$filterComplex = "[0:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
|
|
"[1:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
|
|
"[2:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v2];" +
|
|
"[3:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v3];" +
|
|
"[v0][v1][v2][v3]hstack=inputs=4[outv]"
|
|
} else {
|
|
$filterComplex = "[0:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
|
|
"[1:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
|
|
"[2:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v2];" +
|
|
"[3:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v3];" +
|
|
"[v0][v1][v2][v3]hstack=inputs=4[stacked];" +
|
|
"[stacked]pad=${outputWidth}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[outv]"
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
# Landscape mode scaling - calculate cell dimensions based on output resolution
|
|
$cellWidth2 = [int]($outputWidth / 2) # 960 for 1K, 1920 for 4K
|
|
$cellWidth4 = [int]($outputWidth / 2) # 960 for 1K, 1920 for 4K
|
|
$cellHeight4 = [int]($outputHeight / 2) # 540 for 1K, 1080 for 4K
|
|
$padX = [int]($outputWidth / 4) # 480 for 1K, 960 for 4K
|
|
|
|
switch ($videoCount) {
|
|
2 {
|
|
# 2 videos side-by-side
|
|
$filterComplex = "[0:v]scale=${cellWidth2}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth2}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
|
|
"[1:v]scale=${cellWidth2}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth2}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
|
|
"[v0][v1]hstack=inputs=2[outv]"
|
|
}
|
|
3 {
|
|
# 2x2 grid with S3 centered on bottom row
|
|
$inputArgs += @("-i", $S3)
|
|
$filterComplex = "[0:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
|
|
"[1:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
|
|
"[2:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v2];" +
|
|
"[v0][v1]hstack=inputs=2[top];" +
|
|
"[v2]pad=${outputWidth}:${cellHeight4}:${padX}:0:black[bottom];" +
|
|
"[top][bottom]vstack=inputs=2[outv]"
|
|
}
|
|
4 {
|
|
# 2x2 grid
|
|
$inputArgs += @("-i", $S3, "-i", $S4)
|
|
$filterComplex = "[0:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
|
|
"[1:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
|
|
"[2:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v2];" +
|
|
"[3:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v3];" +
|
|
"[v0][v1]hstack=inputs=2[top];" +
|
|
"[v2][v3]hstack=inputs=2[bottom];" +
|
|
"[top][bottom]vstack=inputs=2[outv]"
|
|
}
|
|
}
|
|
}
|
|
|
|
$ffmpegArgs = $inputArgs + @(
|
|
"-filter_complex", $filterComplex,
|
|
"-map", "[outv]"
|
|
)
|
|
|
|
if (-not $NoAudio) {
|
|
$ffmpegArgs += @("-map", "0:a?")
|
|
}
|
|
|
|
$ffmpegArgs += @(
|
|
"-c:v", "libx264",
|
|
"-preset", "fast",
|
|
"-crf", "8",
|
|
"-pix_fmt", "yuv420p"
|
|
)
|
|
|
|
if (-not $NoAudio) {
|
|
$ffmpegArgs += @("-c:a", "copy")
|
|
}
|
|
|
|
$ffmpegArgs += @("-y", $Output)
|
|
|
|
$commandLine = "ffmpeg " + ($ffmpegArgs | ForEach-Object {
|
|
if ($_ -match '\s') {
|
|
"`"$_`""
|
|
} else {
|
|
$_
|
|
}
|
|
}) -join " "
|
|
|
|
Write-Host "Executing ffmpeg command:" -ForegroundColor Cyan
|
|
Write-Host $commandLine -ForegroundColor Yellow
|
|
Write-Host ""
|
|
|
|
if ($PSCmdlet.ShouldProcess($Output, "Create comparison video")) {
|
|
& ffmpeg @ffmpegArgs
|
|
|
|
if ($LASTEXITCODE -eq 0) {
|
|
Write-Host "`nComparison video created successfully: $Output" -ForegroundColor Green
|
|
} else {
|
|
Write-Error "ffmpeg failed with exit code $LASTEXITCODE"
|
|
exit $LASTEXITCODE
|
|
}
|
|
}
|