376 lines
14 KiB
PowerShell
376 lines
14 KiB
PowerShell
<#
|
||
.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 ' ')"
|
||
} |