<# .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) - 3 videos: Side-by-side (608x1080 each, centered with padding to 1920x1080) - 4 videos: Side-by-side (480x854 each, centered with padding to 1920x1080) Portrait mode (height > width, e.g., 1080x1920) - 4K UHD: - 2 videos: Side-by-side (1216x2160 each, centered with padding to 3840x2160) - 3 videos: Side-by-side (1216x2160 each, centered with padding to 3840x2160) - 4 videos: Side-by-side (960x1708 each, centered with padding to 3840x2160) 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. Only applies when exactly 2 source videos are provided; ignored otherwise. 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). .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. #> [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 ) $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 } if ($Clip -and $videoCount -ne 2) { Write-Warning "-Clip is only supported with exactly 2 source videos; ignoring." $Clip = $false } # --- 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 } # --- Clip mode: shrink output height to the actual scaled height of the sources --- $clipMixedLandscape = $false $clipMixedFilterComplex = $null if ($Clip) { 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 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 ($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]($outputHeight * 0.79) # ~854 for 1K, ~1708 for 4K switch ($videoCount) { 2 { # 2 videos side-by-side, centered $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 $inputArgs += @("-i", $S3) $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 $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][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 } }