diff --git a/Create-PillarBox.cmd b/Create-PillarBox.cmd new file mode 100644 index 0000000..00ff641 --- /dev/null +++ b/Create-PillarBox.cmd @@ -0,0 +1,2 @@ +@echo off +pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Create-PillarBox.ps1" %* diff --git a/Create-PillarBox.ps1 b/Create-PillarBox.ps1 new file mode 100644 index 0000000..664b167 --- /dev/null +++ b/Create-PillarBox.ps1 @@ -0,0 +1,368 @@ +<# +.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'." + } + + # Compute the minimum 16:9 frame that fully contains the source + $minWidthFromHeight = [int][math]::Ceiling($SourceHeight * 16.0 / 9.0) + $minHeightFromWidth = [int][math]::Ceiling($SourceWidth * 9.0 / 16.0) + + $neededWidth = [math]::Max($SourceWidth, $minWidthFromHeight) + $neededHeight = [math]::Max($SourceHeight, $minHeightFromWidth) + + # Pick the smallest standard 16:9 size that contains the source + $unique = $standards | Sort-Object { $_.Width } -Unique + foreach ($s in $unique) { + if ($s.Width -ge $neededWidth -and $s.Height -ge $neededHeight) { + return [pscustomobject]@{ Width = $s.Width; Height = $s.Height } + } + } + + # Source larger than any standard preset: use exact computed dimensions (rounded to even) + $w = $neededWidth + ($neededWidth % 2) + $h = $neededHeight + ($neededHeight % 2) + 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 + } + + $ffmpegOutput | ForEach-Object { Write-Error $_ } + 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 ' ')" +} \ No newline at end of file diff --git a/Crop-Clips.ps1 b/Crop-Clips.ps1 index 9488bf0..618db7d 100644 --- a/Crop-Clips.ps1 +++ b/Crop-Clips.ps1 @@ -40,8 +40,12 @@ function Get-VideoInfo { $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 ($videoStream.r_frame_rate -and $videoStream.r_frame_rate -match '^(\d+)/(\d+)$') { + if ($rFrameRate -and $rFrameRate -match '^(\d+)/(\d+)$') { $num = [double]$Matches[1] $den = [double]$Matches[2] if ($den -ne 0) { @@ -49,10 +53,9 @@ function Get-VideoInfo { } } - $bitRate = $videoStream.bit_rate - if (-not $bitRate) { - $bitRate = $info.format.bit_rate - } + $videoBitRate = & $get $videoStream 'bit_rate' + $formatBitRate = & $get $info.format 'bit_rate' + $bitRate = if ($videoBitRate) { $videoBitRate } else { $formatBitRate } [pscustomobject]@{ Path = $Path @@ -62,7 +65,7 @@ function Get-VideoInfo { Fps = $fps BitRate = $bitRate HasAudio = [bool]$audioStream - VideoBitRate = $videoStream.bit_rate + VideoBitRate = $videoBitRate } } diff --git a/Crop-ClipsKling.ps1 b/Crop-ClipsKling.ps1 index 24147f6..fbeac75 100644 --- a/Crop-ClipsKling.ps1 +++ b/Crop-ClipsKling.ps1 @@ -40,8 +40,12 @@ function Get-VideoInfo { $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 ($videoStream.r_frame_rate -and $videoStream.r_frame_rate -match '^(\d+)/(\d+)$') { + if ($rFrameRate -and $rFrameRate -match '^(\d+)/(\d+)$') { $num = [double]$Matches[1] $den = [double]$Matches[2] if ($den -ne 0) { @@ -49,10 +53,9 @@ function Get-VideoInfo { } } - $bitRate = $videoStream.bit_rate - if (-not $bitRate) { - $bitRate = $info.format.bit_rate - } + $videoBitRate = & $get $videoStream 'bit_rate' + $formatBitRate = & $get $info.format 'bit_rate' + $bitRate = if ($videoBitRate) { $videoBitRate } else { $formatBitRate } [pscustomobject]@{ Path = $Path @@ -62,7 +65,7 @@ function Get-VideoInfo { Fps = $fps BitRate = $bitRate HasAudio = [bool]$audioStream - VideoBitRate = $videoStream.bit_rate + VideoBitRate = $videoBitRate } } diff --git a/Crop-ClipsWan.ps1 b/Crop-ClipsWan.ps1 index 0698dca..6db0962 100644 --- a/Crop-ClipsWan.ps1 +++ b/Crop-ClipsWan.ps1 @@ -40,8 +40,12 @@ function Get-VideoInfo { $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 ($videoStream.r_frame_rate -and $videoStream.r_frame_rate -match '^(\d+)/(\d+)$') { + if ($rFrameRate -and $rFrameRate -match '^(\d+)/(\d+)$') { $num = [double]$Matches[1] $den = [double]$Matches[2] if ($den -ne 0) { @@ -49,10 +53,9 @@ function Get-VideoInfo { } } - $bitRate = $videoStream.bit_rate - if (-not $bitRate) { - $bitRate = $info.format.bit_rate - } + $videoBitRate = & $get $videoStream 'bit_rate' + $formatBitRate = & $get $info.format 'bit_rate' + $bitRate = if ($videoBitRate) { $videoBitRate } else { $formatBitRate } [pscustomobject]@{ Path = $Path @@ -62,7 +65,7 @@ function Get-VideoInfo { Fps = $fps BitRate = $bitRate HasAudio = [bool]$audioStream - VideoBitRate = $videoStream.bit_rate + VideoBitRate = $videoBitRate } }