<# .SYNOPSIS Creates a pillar-boxed video file from a video file. .PARAMETER Source Path to the input video file. Can be absolute or relative to current directory. .mp4 extension is added if not provided. .PARAMETER Target Path to the output video file. Can be absolute or relative to current directory. .mp4 extension is added if not provided. .PARAMETER Effect The effect to apply to the side bars. Possible values are: black: The side bars will be black. white: The side bars will be white. standard: Creates softly blurred side bars generated from the video itself, with a subtle zoom to fill the 16:9 frame. dark: Produces blurred side bars with reduced brightness, creating a darker, more contrasted background that enhances focus on the main video. gaussian: Generates smooth, high‑quality Gaussian‑blurred side bars for a clean, professional broadcast-style background. .PARAMETER Size Size of the output video. Uses the input video's closest size that will fit the 16:9 aspect ratio if not specified. Possible values: 480p, 720p, 1080p, HD, 1440p, 2K, 2160p, 4K, or custom size in format "width:height". .PARAMETER FPS Frames per second for the output video. Uses the input video's FPS if not specified. .PARAMETER NoAudio When present, excludes audio from the source video. .PARAMETER Audio Path to an alternate audio file to use for the target video. Can be absolute or relative to current directory. When specified, this audio replaces the source video's audio. .PARAMETER CRF Constant Rate Factor for video encoding (0-51, lower is better quality). When specified, overrides both the video bitrate and the default -crf 8 value. Only used when re-encoding is required. .DESCRIPTION Creates a pillar-boxed video file from a video file. Will add black bars to the sides of the video to match 16:9 aspect ratio. The black bars will be verticals on each side of the output video if the source video is more portrait-like (e.g. 9:16). Otherwise, the black bars will be horizontal on the top and bottom of the output video. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory = $true)] [string]$Source, [Parameter(Mandatory = $true)] [string]$Target, [Parameter(Mandatory = $true)] [string]$Effect, [Parameter(Mandatory = $false)] [string]$Size = $null, [Parameter(Mandatory = $false)] [double]$FPS = 0, [Parameter(Mandatory = $false)] [switch]$NoAudio, [Parameter(Mandatory = $false)] [string]$Audio = $null, [Parameter(Mandatory = $false)] [int]$CRF = -1 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Get-VideoInfo { param( [Parameter(Mandatory = $true)] [string]$Path ) $json = & ffprobe -v error -print_format json -show_streams -show_format -- "$Path" if ($LASTEXITCODE -ne 0 -or -not $json) { throw "ffprobe failed for: $Path" } $info = $json | ConvertFrom-Json $videoStream = $info.streams | Where-Object { $_.codec_type -eq 'video' } | Select-Object -First 1 if (-not $videoStream) { throw "No video stream found in: $Path" } $audioStream = $info.streams | Where-Object { $_.codec_type -eq 'audio' } | Select-Object -First 1 # Safe property accessor: returns $null when the property is absent (strict-mode friendly). $get = { param($obj, $name) if ($obj -and ($obj.PSObject.Properties.Name -contains $name)) { $obj.$name } else { $null } } $rFrameRate = & $get $videoStream 'r_frame_rate' $fps = $null if ($rFrameRate -and $rFrameRate -match '^(\d+)/(\d+)$') { $num = [double]$Matches[1] $den = [double]$Matches[2] if ($den -ne 0) { $fps = $num / $den } } $videoBitRate = & $get $videoStream 'bit_rate' $formatBitRate = & $get $info.format 'bit_rate' $bitRate = if ($videoBitRate) { $videoBitRate } else { $formatBitRate } [pscustomobject]@{ Path = $Path FileName = [System.IO.Path]::GetFileName($Path) Width = [int]$videoStream.width Height = [int]$videoStream.height Fps = $fps BitRate = $bitRate HasAudio = [bool]$audioStream VideoBitRate = $videoBitRate } } function Format-Quality { param( [Parameter(Mandatory = $false)] $BitRate ) if (-not $BitRate -or $BitRate -notmatch '^\d+$') { return 'unknown' } $kbps = [math]::Round(([double]$BitRate) / 1000.0) return "${kbps}kbps" } function Resolve-Size { param( [Parameter(Mandatory = $false)] [string]$Value, [Parameter(Mandatory = $true)] [int]$SourceWidth, [Parameter(Mandatory = $true)] [int]$SourceHeight ) # Standard 16:9 sizes (largest first only matters for closest-fit lookup) $standards = @( @{ Name = '480p'; Width = 854; Height = 480 }, @{ Name = '720p'; Width = 1280; Height = 720 }, @{ Name = '1080p'; Width = 1920; Height = 1080 }, @{ Name = 'HD'; Width = 1920; Height = 1080 }, @{ Name = '1440p'; Width = 2560; Height = 1440 }, @{ Name = '2K'; Width = 2560; Height = 1440 }, @{ Name = '2160p'; Width = 3840; Height = 2160 }, @{ Name = '4K'; Width = 3840; Height = 2160 } ) if ($Value) { $named = $standards | Where-Object { $_.Name -ieq $Value } | Select-Object -First 1 if ($named) { return [pscustomobject]@{ Width = $named.Width; Height = $named.Height } } if ($Value -match '^(\d+):(\d+)$') { return [pscustomobject]@{ Width = [int]$Matches[1]; Height = [int]$Matches[2] } } throw "Invalid -Size value: $Value. Use a named preset (480p, 720p, 1080p, HD, 1440p, 2K, 2160p, 4K) or 'width:height'." } # Pick the smallest standard 16:9 preset whose longer side (width) is at least # the source's longer side. The foreground is scaled down with # 'force_original_aspect_ratio=decrease', so the source is allowed to shrink # to fit; we only need the preset to be "big enough" relative to the source's # largest dimension. This produces the documented behavior: # 1080x1920 portrait -> 1920x1080 (vertical bars on either side) # 720x1280 portrait -> 1280x720 # 2560x1080 landscape -> 2560x1440 (horizontal bars top/bottom) $longSide = [math]::Max($SourceWidth, $SourceHeight) $unique = $standards | Sort-Object { $_.Width } -Unique foreach ($s in $unique) { if ($s.Width -ge $longSide) { return [pscustomobject]@{ Width = $s.Width; Height = $s.Height } } } # Source larger than any standard preset: build a 16:9 frame whose width # matches the source's longer side (rounded to even). $w = $longSide + ($longSide % 2) $h = [int][math]::Ceiling($w * 9.0 / 16.0) if ($h % 2) { $h++ } return [pscustomobject]@{ Width = $w; Height = $h } } # Append .mp4 extension if missing if (-not [System.IO.Path]::GetExtension($Source)) { $Source = "$Source.mp4" } if (-not [System.IO.Path]::GetExtension($Target)) { $Target = "$Target.mp4" } if (-not (Test-Path -LiteralPath $Source)) { throw "Source video not found: $Source" } $sourceFull = (Resolve-Path -LiteralPath $Source).Path # Resolve Target to an absolute path (may not exist yet) if ([System.IO.Path]::IsPathRooted($Target)) { $targetFull = $Target } else { $targetFull = Join-Path (Get-Location).Path $Target } if ($Audio) { if (-not (Test-Path -LiteralPath $Audio)) { throw "Audio file not found: $Audio" } $audioFull = (Resolve-Path -LiteralPath $Audio).Path } else { $audioFull = $null } if ($CRF -ne -1 -and ($CRF -lt 0 -or $CRF -gt 51)) { throw "Invalid -CRF value: $CRF. Must be in range 0-51." } $validEffects = @('black', 'white', 'standard', 'dark', 'gaussian') $effectLower = $Effect.ToLower() if ($effectLower -notin $validEffects) { throw "Invalid -Effect value: $Effect. Must be one of: $($validEffects -join ', ')" } $inInfo = Get-VideoInfo -Path $sourceFull $inRes = "{0}x{1}" -f $inInfo.Width, $inInfo.Height $inFpsText = if ($inInfo.Fps) { "{0:0.###}" -f $inInfo.Fps } else { 'unknown' } $inQuality = Format-Quality -BitRate $inInfo.BitRate $inAudioText = if ($inInfo.HasAudio) { 'yes' } else { 'no' } Write-Host "Input : Filename=$($inInfo.FileName) Resolution=$inRes FPS=$inFpsText Quality=$inQuality Audio=$inAudioText" $outSize = Resolve-Size -Value $Size -SourceWidth $inInfo.Width -SourceHeight $inInfo.Height $outWidth = $outSize.Width $outHeight = $outSize.Height # Force even dimensions for yuv420p compatibility if ($outWidth % 2) { $outWidth++ } if ($outHeight % 2) { $outHeight++ } # Scale the source so it fits entirely inside the output frame, preserving aspect ratio. $scaleFg = "scale=w=${outWidth}:h=${outHeight}:force_original_aspect_ratio=decrease" # Build the video filter graph depending on the requested -Effect. # black/white : simple pad with the solid color (uses -vf) # standard : softly blurred background with a subtle zoom (uses -filter_complex) # dark : blurred background with reduced brightness/saturation (uses -filter_complex) # gaussian : high-quality Gaussian-blurred background (uses -filter_complex) $useFilterComplex = $effectLower -in @('standard', 'dark', 'gaussian') if (-not $useFilterComplex) { $padExpr = "pad=${outWidth}:${outHeight}:(ow-iw)/2:(oh-ih)/2:${effectLower}" $vf = "${scaleFg},${padExpr},setsar=1" } else { # Background scaling: fill frame entirely, then crop. 'standard' adds a subtle ~10% zoom. if ($effectLower -eq 'standard') { $zoomW = [int][math]::Ceiling($outWidth * 1.1) $zoomH = [int][math]::Ceiling($outHeight * 1.1) if ($zoomW % 2) { $zoomW++ } if ($zoomH % 2) { $zoomH++ } $bgScale = "scale=w=${zoomW}:h=${zoomH}:force_original_aspect_ratio=increase,crop=${outWidth}:${outHeight}" } else { $bgScale = "scale=w=${outWidth}:h=${outHeight}:force_original_aspect_ratio=increase,crop=${outWidth}:${outHeight}" } # Per-effect blur and color treatment for the background. switch ($effectLower) { 'standard' { $bgEffect = 'boxblur=20:1' } 'dark' { $bgEffect = 'boxblur=20:1,eq=brightness=-0.25:saturation=0.8' } 'gaussian' { $bgEffect = 'gblur=sigma=30' } } $filterComplex = "[0:v]split=2[bg][fg];" ` + "[bg]${bgScale},${bgEffect},setsar=1[blurred];" ` + "[fg]${scaleFg},setsar=1[scaled];" ` + "[blurred][scaled]overlay=(W-w)/2:(H-h)/2[outv]" } # Effective output FPS for log display $effFps = if ($FPS -gt 0) { $FPS } elseif ($inInfo.Fps) { $inInfo.Fps } else { $null } $effFpsText = if ($effFps) { "{0:0.###}" -f $effFps } else { 'unknown' } # Build ffmpeg arguments $ffArgs = @('-y', '-i', $sourceFull) if ($audioFull) { $ffArgs += @('-i', $audioFull) } if ($useFilterComplex) { $ffArgs += @('-filter_complex', $filterComplex) $videoMap = '[outv]' } else { $ffArgs += @('-vf', $vf) $videoMap = '0:v:0' } if ($FPS -gt 0) { $ffArgs += @('-r', ("{0}" -f $FPS)) } # Video encoding: prefer CRF if specified, otherwise preserve source bitrate, else fallback to -crf 8. if ($CRF -ge 0) { $ffArgs += @('-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'slow', '-crf', "$CRF") } elseif ($inInfo.VideoBitRate -and $inInfo.VideoBitRate -match '^\d+$') { $ffArgs += @('-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'slow', '-b:v', $inInfo.VideoBitRate) } else { $ffArgs += @('-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'slow', '-crf', '8') } # Audio handling. Always emit -map for video so filter_complex output is wired correctly; # audio source depends on -NoAudio / -Audio / source-has-audio. if ($NoAudio) { $ffArgs += @('-map', $videoMap, '-an') } elseif ($audioFull) { # Map video from filter (or input 0) and audio from input 1, stop at shortest stream $ffArgs += @('-map', $videoMap, '-map', '1:a:0', '-c:a', 'aac', '-b:a', '192k', '-shortest') } elseif ($inInfo.HasAudio) { $ffArgs += @('-map', $videoMap, '-map', '0:a:0?', '-c:a', 'copy') } else { $ffArgs += @('-map', $videoMap, '-an') } $ffArgs += @($targetFull) Write-Host "Output: Filename=$([System.IO.Path]::GetFileName($targetFull)) Resolution=${outWidth}x${outHeight} FPS=$effFpsText Effect=$effectLower" # Ensure target directory exists $targetDir = [System.IO.Path]::GetDirectoryName($targetFull) if ($targetDir -and -not (Test-Path -LiteralPath $targetDir)) { if ($PSCmdlet.ShouldProcess($targetDir, 'Create output directory')) { New-Item -ItemType Directory -Path $targetDir -Force | Out-Null } } if ($PSCmdlet.ShouldProcess($targetFull, 'Create pillar-boxed video with ffmpeg')) { $ffmpegOutput = & ffmpeg @ffArgs 2>&1 $ffmpegExit = $LASTEXITCODE if ($ffmpegExit -ne 0) { if (Test-Path -LiteralPath $targetFull) { Remove-Item -LiteralPath $targetFull -Force -ErrorAction SilentlyContinue } # Use Write-Host (not Write-Error) so the full ffmpeg output is shown; # under $ErrorActionPreference = 'Stop' a piped Write-Error would terminate # on the first line, swallowing the rest of the diagnostic output and the # final 'throw' below. $ffmpegOutput | ForEach-Object { Write-Host $_ } Write-Host "ffmpeg $($ffArgs -join ' ')" throw "ffmpeg failed while creating pillar-boxed video: $($inInfo.FileName)" } $outInfo = Get-VideoInfo -Path $targetFull $outRes = "{0}x{1}" -f $outInfo.Width, $outInfo.Height $outFpsText = if ($outInfo.Fps) { "{0:0.###}" -f $outInfo.Fps } else { 'unknown' } $outQuality = Format-Quality -BitRate $outInfo.BitRate $outAudioText = if ($outInfo.HasAudio) { 'yes' } else { 'no' } Write-Host "Done : Filename=$($outInfo.FileName) Resolution=$outRes FPS=$outFpsText Quality=$outQuality Audio=$outAudioText" } else { $formattedArgs = $ffArgs | ForEach-Object { if ($_ -match '\s') { "`"$_`"" } else { $_ } } Write-Host "WhatIf: Would create pillar-boxed video with ffmpeg $($formattedArgs -join ' ')" }