Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9c0eb3303 | |||
| 1e78cc67ae |
+96
-8
@@ -1,6 +1,8 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clones (copies) a video file using ffmpeg.
|
||||
Supports MP4 (H.264), WebM and Matroska (MKV) sources, including YouTube/Google DASH downloads encoded as VP9 + Opus/AAC.
|
||||
Non-H.264 video and non-MP4-compatible audio are automatically transcoded to H.264 / AAC so the target is always a standard MP4.
|
||||
When Sequence is provided and EndFrame is -1:
|
||||
- First, the last frame of the video is saved as a PNG image (using the Sequence value as filename).
|
||||
- After, the video is cloned (copied) from StartFrame to the second-to-last frame (excluding the last frame).
|
||||
@@ -8,7 +10,8 @@
|
||||
- First, the frame at EndFrame index is saved as a PNG image (using the Sequence value as filename).
|
||||
- After, the video is cloned (copied) from StartFrame to EndFrame-1 (excluding the extracted frame).
|
||||
.PARAMETER Source
|
||||
Path to the input video file. Can be absolute or relative to current directory. .mp4 extension is added if not provided.
|
||||
Path to the input video file. Can be absolute or relative to current directory.
|
||||
If the path has no extension, .mp4, .webm and .mkv are tried in turn.
|
||||
.PARAMETER Target
|
||||
Path to the output video file. Can be absolute or relative to current directory. .mp4 extension is added if not provided.
|
||||
.PARAMETER StartFrame
|
||||
@@ -76,12 +79,26 @@ $ErrorActionPreference = 'Stop'
|
||||
|
||||
$SourcePath = $Source
|
||||
|
||||
if (-not $SourcePath.EndsWith('.mp4', [StringComparison]::OrdinalIgnoreCase)) {
|
||||
$SourcePath += '.mp4'
|
||||
# Resolve source path: accept .mp4, .webm or .mkv. If the path doesn't exist as-is,
|
||||
# try appending each supported extension in turn (preserving the original .mp4-default
|
||||
# behaviour while adding WebM/MKV support for YouTube/Google DASH downloads).
|
||||
$supportedSourceExts = @('.mp4', '.webm', '.mkv')
|
||||
if (-not (Test-Path -LiteralPath $SourcePath)) {
|
||||
$resolved = $null
|
||||
foreach ($ext in $supportedSourceExts) {
|
||||
$candidate = "$SourcePath$ext"
|
||||
if (Test-Path -LiteralPath $candidate) {
|
||||
$resolved = $candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($resolved) {
|
||||
$SourcePath = $resolved
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $SourcePath)) {
|
||||
Write-Error ('Source video file not found: {0}' -f $SourcePath)
|
||||
Write-Error ('Source video file not found: {0}' -f $Source)
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -103,12 +120,22 @@ if ($NoAudio -and -not [string]::IsNullOrWhiteSpace($Audio)) {
|
||||
}
|
||||
|
||||
$AudioPath = $null
|
||||
$externalAudioCodec = ''
|
||||
$externalAudioCodecMismatch = $false
|
||||
if (-not [string]::IsNullOrWhiteSpace($Audio)) {
|
||||
$AudioPath = $Audio
|
||||
if (-not (Test-Path -LiteralPath $AudioPath)) {
|
||||
Write-Error ('Audio file not found: {0}' -f $AudioPath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Probe the external audio file's codec so a non-MP4-compatible codec
|
||||
# (e.g. Opus from a YouTube/DASH download) is transcoded to AAC instead
|
||||
# of being stream-copied into the MP4 container.
|
||||
$ffprobeExtAudioOutput = & ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 -- $AudioPath 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$externalAudioCodec = ($ffprobeExtAudioOutput | Select-Object -First 1).Trim().ToLowerInvariant()
|
||||
}
|
||||
}
|
||||
|
||||
$sizeMap = @{
|
||||
@@ -190,6 +217,30 @@ if ($ffprobeExit -eq 0) {
|
||||
}
|
||||
}
|
||||
|
||||
# Probe source video and audio codecs. The MP4 container only supports a limited set
|
||||
# of codecs: when the source is VP9/VP8/AV1 video or Opus/Vorbis/etc. audio (typical
|
||||
# YouTube/Google DASH downloads), we must transcode rather than stream-copy.
|
||||
$ffprobeVideoCodecOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1
|
||||
$sourceVideoCodec = ''
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$sourceVideoCodec = ($ffprobeVideoCodecOutput | Select-Object -First 1).Trim().ToLowerInvariant()
|
||||
}
|
||||
|
||||
$ffprobeAudioCodecOutput = & ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1
|
||||
$sourceAudioCodec = ''
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$sourceAudioCodec = ($ffprobeAudioCodecOutput | Select-Object -First 1).Trim().ToLowerInvariant()
|
||||
}
|
||||
|
||||
# A non-H.264 source forces a video re-encode regardless of trimming/scaling/FPS changes.
|
||||
$videoCodecMismatch = ($sourceVideoCodec -and $sourceVideoCodec -ne 'h264')
|
||||
|
||||
# MP4-container-compatible audio codecs. Anything else (opus, vorbis, flac, ...) must
|
||||
# be transcoded to AAC when stream-copying audio.
|
||||
$mp4AudioCodecs = @('aac', 'mp3', 'ac3', 'eac3', 'alac')
|
||||
$audioCodecMismatch = ($sourceAudioCodec -and ($mp4AudioCodecs -notcontains $sourceAudioCodec))
|
||||
$externalAudioCodecMismatch = ($externalAudioCodec -and ($mp4AudioCodecs -notcontains $externalAudioCodec))
|
||||
|
||||
if ($StartFrame -lt 0 -or $StartFrame -gt $maxFrameIndex) {
|
||||
Write-Error ('StartFrame {0} is out of range. Valid range: 0 to {1}' -f $StartFrame, $maxFrameIndex)
|
||||
exit 1
|
||||
@@ -258,7 +309,17 @@ if (-not [string]::IsNullOrWhiteSpace($Sequence)) {
|
||||
|
||||
$frameCount = $actualEndFrame - $StartFrame + 1
|
||||
|
||||
Write-Host ('Cloning video: {0}' -f $Source)
|
||||
Write-Host ('Cloning video: {0}' -f $SourcePath)
|
||||
if ($sourceVideoCodec) {
|
||||
Write-Host (' Source codec: video={0}{1} audio={2}{3}' -f `
|
||||
$sourceVideoCodec, $(if ($videoCodecMismatch) { ' (will transcode to h264)' } else { '' }), `
|
||||
$(if ($sourceAudioCodec) { $sourceAudioCodec } else { 'none' }), `
|
||||
$(if ($audioCodecMismatch) { ' (will transcode to aac)' } else { '' }))
|
||||
}
|
||||
if ($AudioPath -and $externalAudioCodec) {
|
||||
Write-Host (' External audio codec: {0}{1}' -f `
|
||||
$externalAudioCodec, $(if ($externalAudioCodecMismatch) { ' (will transcode to aac)' } else { '' }))
|
||||
}
|
||||
Write-Host (' Frame range: {0} to {1} ({2} frames)' -f $StartFrame, $actualEndFrame, $frameCount)
|
||||
if ($scaleWidth -gt 0) {
|
||||
Write-Host (' Size: {0}x{1}' -f $scaleWidth, $scaleHeight)
|
||||
@@ -277,7 +338,7 @@ if ($AudioPath) {
|
||||
$ffmpegArgs += @('-i', $AudioPath)
|
||||
}
|
||||
|
||||
$needsReencode = $scaleWidth -gt 0 -or $FPS -gt 0
|
||||
$needsReencode = $scaleWidth -gt 0 -or $FPS -gt 0 -or $videoCodecMismatch
|
||||
|
||||
if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
|
||||
$filterParts = @('select=between(n\,{0}\,{1})' -f $StartFrame, $actualEndFrame)
|
||||
@@ -292,6 +353,9 @@ if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
|
||||
$ffmpegArgs += @(
|
||||
'-vf', $videoFilter
|
||||
'-vsync', 'vfr'
|
||||
'-c:v', 'libx264'
|
||||
'-pix_fmt', 'yuv420p'
|
||||
'-preset', 'slow'
|
||||
)
|
||||
if ($CRF -ge 0) {
|
||||
$ffmpegArgs += @('-crf', $CRF.ToString())
|
||||
@@ -308,8 +372,15 @@ if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
|
||||
if ($FPS -gt 0) {
|
||||
$filterParts += ('fps={0}' -f $FPS)
|
||||
}
|
||||
$videoFilter = $filterParts -join ','
|
||||
$ffmpegArgs += @('-vf', $videoFilter)
|
||||
if ($filterParts.Count -gt 0) {
|
||||
$videoFilter = $filterParts -join ','
|
||||
$ffmpegArgs += @('-vf', $videoFilter)
|
||||
}
|
||||
$ffmpegArgs += @(
|
||||
'-c:v', 'libx264'
|
||||
'-pix_fmt', 'yuv420p'
|
||||
'-preset', 'slow'
|
||||
)
|
||||
if ($CRF -ge 0) {
|
||||
$ffmpegArgs += @('-crf', $CRF.ToString())
|
||||
} elseif ($videoBitrate) {
|
||||
@@ -335,6 +406,15 @@ if ($NoAudio) {
|
||||
'-t', $durationStr
|
||||
'-c:a', 'aac'
|
||||
)
|
||||
} elseif ($externalAudioCodecMismatch) {
|
||||
# External audio (e.g. Opus from a YouTube/DASH WebM) cannot be stream-copied into MP4.
|
||||
$ffmpegArgs += @(
|
||||
'-map', '0:v'
|
||||
'-map', '1:a'
|
||||
'-c:a', 'aac'
|
||||
'-b:a', '192k'
|
||||
'-shortest'
|
||||
)
|
||||
} else {
|
||||
$ffmpegArgs += @(
|
||||
'-map', '0:v'
|
||||
@@ -351,6 +431,14 @@ if ($NoAudio) {
|
||||
$endTimeStr = $endTime.ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture)
|
||||
$audioFilter = "atrim=start=${startTimeStr}:end=${endTimeStr},asetpts=PTS-STARTPTS"
|
||||
$ffmpegArgs += @('-af', $audioFilter)
|
||||
if ($audioCodecMismatch) {
|
||||
# ffmpeg will already re-encode here (no -c:a copy), but force AAC explicitly
|
||||
# so the output is MP4-compatible regardless of source codec.
|
||||
$ffmpegArgs += @('-c:a', 'aac', '-b:a', '192k')
|
||||
}
|
||||
} elseif ($audioCodecMismatch) {
|
||||
# Source audio (e.g. Opus from a YouTube/DASH WebM) cannot be stream-copied into MP4.
|
||||
$ffmpegArgs += @('-c:a', 'aac', '-b:a', '192k')
|
||||
} else {
|
||||
$ffmpegArgs += @('-c:a', 'copy')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Create-PillarBox.ps1" %*
|
||||
@@ -0,0 +1,376 @@
|
||||
<#
|
||||
.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 ' ')"
|
||||
}
|
||||
+9
-6
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-6
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-6
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user